titles = new ArrayList<>();
+
+ MyAdapter(FragmentManager fm) {
+ super(fm);
+ }
+
+ @Override
+ public Fragment getItem(int position) {
+ return fragments.get(position);
+ }
+
+ @Override
+ public int getCount() {
+ return titles.size();
+ }
+
+ @Override
+ public CharSequence getPageTitle(int position) {
+ return titles.get(position);
+ }
+
+ void addFragment(Fragment fragment, String title) {
+ fragments.add(fragment);
+ titles.add(title);
+ }
+ }
+
+ @Override
+ protected void onResume() {
+ super.onResume();
+ if (SoundRecorderApplication.getInstance().isNightModeEnabled()) {
+ AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES); //For night mode theme
+ } else {
+ AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO); //For day mode theme
+ }
+ LocalBroadcastManager.getInstance(this).registerReceiver(
+ mMessageReceiver,
+ new IntentFilter(EventBroadcaster.SHOW_SNACKBAR)
+ );
+ }
+
+ @Override
+ protected void onPause() {
+ super.onPause();
+ try {
+ LocalBroadcastManager.getInstance(this).unregisterReceiver(mMessageReceiver);
+ } catch (Exception exc) {
+ Crashlytics.logException(exc);
+ Log.e(LOG_TAG, "Error unregistering MessageReceiver", exc);
+ }
+ }
+
+}
diff --git a/app/src/main/java/by/naxa/soundrecorder/activities/SettingsActivity.java b/app/src/main/java/by/naxa/soundrecorder/activities/SettingsActivity.java
new file mode 100644
index 00000000..ec6f9047
--- /dev/null
+++ b/app/src/main/java/by/naxa/soundrecorder/activities/SettingsActivity.java
@@ -0,0 +1,63 @@
+package by.naxa.soundrecorder.activities;
+
+import android.os.Bundle;
+import android.preference.PreferenceActivity;
+import androidx.annotation.Nullable;
+import androidx.appcompat.app.ActionBar;
+import androidx.appcompat.app.AppCompatActivity;
+import androidx.appcompat.app.AppCompatDelegate;
+import androidx.appcompat.widget.Toolbar;
+
+import by.naxa.soundrecorder.R;
+import by.naxa.soundrecorder.SoundRecorderApplication;
+import by.naxa.soundrecorder.fragments.SettingsFragment;
+
+/**
+ * A {@link PreferenceActivity} that presents a set of application settings.
+ * On handset devices, settings are presented as a single list.
+ *
+ * See
+ * Material Design: Android Settings for design guidelines and the Settings API Guide
+ * for more information on developing a Settings UI.
+ *
+ * Created by Daniel on 5/22/2017.
+ */
+public class SettingsActivity extends AppCompatActivity {
+
+ @Override
+ protected void onCreate(@Nullable Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ if (SoundRecorderApplication.getInstance().isNightModeEnabled()) {
+ AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES); //For night mode theme
+ } else {
+ AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO); //For day mode theme
+ }
+ setContentView(R.layout.activity_preferences);
+ setupActionBar();
+
+ getFragmentManager()
+ .beginTransaction()
+ .replace(R.id.container, new SettingsFragment())
+ .commit();
+ }
+
+ /**
+ * Set up the {@link android.app.ActionBar}.
+ */
+ private void setupActionBar() {
+ final Toolbar toolbar = findViewById(R.id.toolbar);
+ if (toolbar != null) {
+ setSupportActionBar(toolbar);
+ }
+
+ final ActionBar actionBar = getSupportActionBar();
+ if (actionBar != null) {
+ actionBar.setTitle(R.string.action_settings);
+ // Show the Up button in the action bar.
+ actionBar.setDisplayHomeAsUpEnabled(true);
+ actionBar.setDisplayShowHomeEnabled(true);
+ }
+ }
+
+}
diff --git a/app/src/main/java/by/naxa/soundrecorder/adapters/FileViewerAdapter.java b/app/src/main/java/by/naxa/soundrecorder/adapters/FileViewerAdapter.java
new file mode 100644
index 00000000..c5d2e6d4
--- /dev/null
+++ b/app/src/main/java/by/naxa/soundrecorder/adapters/FileViewerAdapter.java
@@ -0,0 +1,350 @@
+package by.naxa.soundrecorder.adapters;
+
+import android.content.Context;
+import android.content.DialogInterface;
+import android.content.Intent;
+import android.net.Uri;
+import android.os.Environment;
+import android.text.Editable;
+import android.text.format.DateUtils;
+import android.util.Log;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.Window;
+import android.view.WindowManager;
+import android.widget.ImageView;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import com.crashlytics.android.Crashlytics;
+import com.google.android.material.textfield.TextInputEditText;
+
+import java.io.File;
+import java.util.ArrayList;
+
+import androidx.annotation.NonNull;
+import androidx.appcompat.app.AlertDialog;
+import androidx.core.content.FileProvider;
+import androidx.fragment.app.FragmentActivity;
+import androidx.fragment.app.FragmentTransaction;
+import androidx.recyclerview.widget.LinearLayoutManager;
+import androidx.recyclerview.widget.RecyclerView;
+import by.naxa.soundrecorder.BuildConfig;
+import by.naxa.soundrecorder.DBHelper;
+import by.naxa.soundrecorder.R;
+import by.naxa.soundrecorder.RecordingItem;
+import by.naxa.soundrecorder.fragments.PlaybackFragment;
+import by.naxa.soundrecorder.listeners.OnDatabaseChangedListener;
+import by.naxa.soundrecorder.listeners.OnSingleClickListener;
+import by.naxa.soundrecorder.util.EventBroadcaster;
+import by.naxa.soundrecorder.util.Paths;
+import by.naxa.soundrecorder.util.TimeUtils;
+import io.fabric.sdk.android.Fabric;
+
+/**
+ * Created by Daniel on 12/29/2014.
+ */
+public class FileViewerAdapter extends RecyclerView.Adapter
+ implements OnDatabaseChangedListener {
+
+ private static final String LOG_TAG = "FileViewerAdapter";
+
+ private DBHelper mDatabase;
+
+ private Context mContext;
+ private final LinearLayoutManager llm;
+
+ public FileViewerAdapter(Context context, LinearLayoutManager linearLayoutManager) {
+ super();
+ mContext = context;
+ mDatabase = new DBHelper(mContext);
+ DBHelper.setOnDatabaseChangedListener(this);
+ llm = linearLayoutManager;
+ }
+
+ @Override
+ public void onBindViewHolder(@NonNull final RecordingsViewHolder holder, int position) {
+
+ RecordingItem item = getItem(position);
+ long itemDuration = item.getLength();
+
+ holder.vName.setText(item.getName());
+ holder.vLength.setText(TimeUtils.formatDuration(itemDuration));
+ holder.vDateAdded.setText(
+ DateUtils.formatDateTime(
+ mContext,
+ item.getTime(),
+ DateUtils.FORMAT_SHOW_DATE | DateUtils.FORMAT_NUMERIC_DATE | DateUtils.FORMAT_SHOW_TIME | DateUtils.FORMAT_SHOW_YEAR
+ )
+ );
+
+ // define an on click listener to open PlaybackFragment
+ holder.cardView.setOnClickListener(new OnSingleClickListener() {
+ @Override
+ public void onSingleClick(View view) {
+ try {
+ PlaybackFragment playbackFragment =
+ new PlaybackFragment().newInstance(getItem(holder.getPosition()));
+
+ FragmentTransaction transaction = ((FragmentActivity) mContext)
+ .getSupportFragmentManager()
+ .beginTransaction();
+
+ playbackFragment.show(transaction, "dialog_playback");
+ } catch (Exception e) {
+ Log.e(LOG_TAG, "exception", e);
+ Crashlytics.logException(e);
+ }
+ }
+ });
+
+ holder.options.setOnClickListener(new OnSingleClickListener() {
+ @Override
+ public void onSingleClick(View view) {
+ presentFileOptions(holder);
+ }
+ });
+
+ holder.cardView.setOnLongClickListener(new View.OnLongClickListener() {
+ @Override
+ public boolean onLongClick(View v) {
+ return presentFileOptions(holder);
+ }
+ });
+ }
+
+ private boolean presentFileOptions(@NonNull final RecordingsViewHolder holder) {
+ final ArrayList entries = new ArrayList<>();
+ entries.add(mContext.getString(R.string.dialog_file_share));
+ entries.add(mContext.getString(R.string.dialog_file_rename));
+ entries.add(mContext.getString(R.string.dialog_file_delete));
+
+ final CharSequence[] items = entries.toArray(new CharSequence[entries.size()]);
+
+
+ // File delete confirm
+ AlertDialog.Builder builder = new AlertDialog.Builder(mContext);
+ builder.setTitle(mContext.getString(R.string.dialog_title_options));
+ builder.setItems(items, new DialogInterface.OnClickListener() {
+ public void onClick(DialogInterface dialog, int item) {
+ if (item == 0) {
+ shareFileDialog(holder.getPosition());
+ } else if (item == 1) {
+ renameFileDialog(holder.getPosition());
+ } else if (item == 2) {
+ deleteFileDialog(holder.getPosition());
+ }
+ }
+ });
+ builder.setCancelable(true);
+ builder.setNegativeButton(mContext.getString(R.string.dialog_action_cancel),
+ new DialogInterface.OnClickListener() {
+ public void onClick(DialogInterface dialog, int id) {
+ dialog.cancel();
+ }
+ });
+
+ AlertDialog alert = builder.create();
+ alert.show();
+
+ return true;
+ }
+
+ @Override
+ @NonNull
+ public RecordingsViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
+
+ View itemView = LayoutInflater.
+ from(parent.getContext()).
+ inflate(R.layout.card_view, parent, false);
+
+ mContext = parent.getContext();
+
+ return new RecordingsViewHolder(itemView);
+ }
+
+ static class RecordingsViewHolder extends RecyclerView.ViewHolder {
+ TextView vName;
+ TextView vLength;
+ TextView vDateAdded;
+ View cardView;
+ ImageView options;
+
+ RecordingsViewHolder(View v) {
+ super(v);
+ vName = v.findViewById(R.id.file_name_text);
+ vLength = v.findViewById(R.id.file_length_text);
+ vDateAdded = v.findViewById(R.id.file_date_added_text);
+ cardView = v.findViewById(R.id.card_view);
+ options = v.findViewById(R.id.optionsImageView);
+ }
+ }
+
+ @Override
+ public int getItemCount() {
+ return mDatabase.getCount();
+ }
+
+ public RecordingItem getItem(int position) {
+ return mDatabase.getItemAt(position);
+ }
+
+ @Override
+ public void onNewDatabaseEntryAdded() {
+ //item added to top of the list
+ notifyItemInserted(getItemCount() - 1);
+ llm.scrollToPosition(getItemCount() - 1);
+ }
+
+ @Override
+ //TODO
+ public void onDatabaseEntryRenamed() {
+
+ }
+
+ public void remove(int position) {
+ //remove item from database, recyclerview and storage
+
+ //delete file from storage
+ File file = new File(getItem(position).getFilePath());
+ if (!file.delete()) {
+ Toast.makeText(mContext,
+ String.format(mContext.getString(R.string.toast_file_delete_failed),
+ getItem(position).getName()),
+ Toast.LENGTH_LONG).show();
+ return;
+ }
+
+ Toast.makeText(
+ mContext,
+ String.format(
+ mContext.getString(R.string.toast_file_delete),
+ getItem(position).getName()
+ ),
+ Toast.LENGTH_SHORT
+ ).show();
+
+ mDatabase.removeItemWithId(getItem(position).getId());
+ notifyItemRemoved(position);
+ }
+
+ //TODO
+ public void removeOutOfApp(String filePath) {
+ //user deletes a saved recording out of the application through another application
+ }
+
+ /**
+ * rename a file
+ */
+ public void rename(int position, String name) {
+ final String mFilePath = Paths.combine(
+ Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MUSIC),
+ Paths.SOUND_RECORDER_FOLDER, name);
+ final File f = new File(mFilePath);
+
+ if (f.exists() && !f.isDirectory()) {
+ //file name is not unique, cannot rename file.
+ Toast.makeText(mContext,
+ String.format(mContext.getString(R.string.toast_file_exists), name),
+ Toast.LENGTH_LONG).show();
+ } else {
+ //file name is unique, rename file
+ File oldFilePath = new File(getItem(position).getFilePath());
+ if (!oldFilePath.renameTo(f)) {
+ Toast.makeText(mContext,
+ String.format(mContext.getString(R.string.toast_file_rename_failed), name),
+ Toast.LENGTH_LONG).show();
+ return;
+ }
+ mDatabase.renameItem(getItem(position), name, mFilePath);
+ notifyItemChanged(position);
+ }
+ }
+
+ private void shareFileDialog(int position) {
+ Intent shareIntent = new Intent();
+ shareIntent.setAction(Intent.ACTION_SEND);
+ final Uri uri = FileProvider.getUriForFile(mContext,
+ BuildConfig.APPLICATION_ID + ".fileprovider",
+ new File(getItem(position).getFilePath()));
+ shareIntent.putExtra(Intent.EXTRA_STREAM, uri);
+ shareIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
+ shareIntent.setType("audio/mp4");
+ mContext.startActivity(Intent.createChooser(shareIntent, mContext.getText(R.string.send_to)));
+ }
+
+ private void renameFileDialog(final int position) {
+ // File rename dialog
+ AlertDialog.Builder renameFileBuilder = new AlertDialog.Builder(mContext);
+
+ LayoutInflater inflater = LayoutInflater.from(mContext);
+ View view = inflater.inflate(R.layout.dialog_rename_file, null);
+
+ final TextInputEditText input = view.findViewById(R.id.new_name);
+
+ renameFileBuilder.setTitle(mContext.getString(R.string.dialog_title_rename));
+ renameFileBuilder.setCancelable(true);
+ renameFileBuilder.setPositiveButton(mContext.getString(R.string.dialog_action_ok),
+ new DialogInterface.OnClickListener() {
+ public void onClick(DialogInterface dialog, int id) {
+ try {
+ final Editable editable = input.getText();
+ if (editable == null)
+ return;
+ final String value = editable.toString().trim() + ".mp4";
+ rename(position, value);
+ } catch (Exception e) {
+ if (Fabric.isInitialized()) Crashlytics.logException(e);
+ Log.e(LOG_TAG, "exception", e);
+ EventBroadcaster.send(mContext, mContext.getString(R.string.error_rename_file));
+ }
+
+ dialog.cancel();
+ }
+ });
+ renameFileBuilder.setNegativeButton(mContext.getString(R.string.dialog_action_cancel),
+ new CancelDialogListener());
+
+ renameFileBuilder.setView(view);
+ AlertDialog alert = renameFileBuilder.create();
+ final Window window = alert.getWindow();
+ if (window != null) {
+ window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_VISIBLE);
+ }
+ alert.show();
+ }
+
+ private void deleteFileDialog(final int position) {
+ // File delete confirm
+ AlertDialog.Builder confirmDelete = new AlertDialog.Builder(mContext);
+ confirmDelete.setTitle(mContext.getString(R.string.dialog_title_delete));
+ confirmDelete.setMessage(mContext.getString(R.string.dialog_text_delete));
+ confirmDelete.setCancelable(true);
+ confirmDelete.setPositiveButton(mContext.getString(R.string.dialog_action_yes_delete),
+ new DialogInterface.OnClickListener() {
+ public void onClick(DialogInterface dialog, int id) {
+ try {
+ //remove item from database, recyclerview, and storage
+ remove(position);
+ } catch (Exception e) {
+ Log.e(LOG_TAG, "exception", e);
+ }
+
+ dialog.cancel();
+ }
+ });
+ confirmDelete.setNegativeButton(mContext.getString(R.string.dialog_action_no),
+ new CancelDialogListener());
+
+ AlertDialog alert = confirmDelete.create();
+ alert.show();
+ }
+
+ static final class CancelDialogListener implements DialogInterface.OnClickListener {
+ @Override
+ public void onClick(DialogInterface dialog, int id) {
+ dialog.cancel();
+ }
+ }
+}
diff --git a/app/src/main/java/by/naxa/soundrecorder/fragments/FileViewerFragment.java b/app/src/main/java/by/naxa/soundrecorder/fragments/FileViewerFragment.java
new file mode 100644
index 00000000..6372a749
--- /dev/null
+++ b/app/src/main/java/by/naxa/soundrecorder/fragments/FileViewerFragment.java
@@ -0,0 +1,137 @@
+package by.naxa.soundrecorder.fragments;
+
+import android.os.Bundle;
+import android.os.Environment;
+import android.os.FileObserver;
+import android.util.Log;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.TextView;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.fragment.app.Fragment;
+import androidx.recyclerview.widget.DefaultItemAnimator;
+import androidx.recyclerview.widget.LinearLayoutManager;
+import androidx.recyclerview.widget.RecyclerView;
+import by.naxa.soundrecorder.R;
+import by.naxa.soundrecorder.adapters.FileViewerAdapter;
+import by.naxa.soundrecorder.util.Paths;
+
+/**
+ * Created by Daniel on 12/23/2014.
+ */
+public class FileViewerFragment extends Fragment {
+ private static final String LOG_TAG = "FileViewerFragment";
+
+ private FileViewerAdapter mFileViewerAdapter;
+ private RecyclerView mRecyclerView;
+ private TextView mTextView;
+ private RecyclerView.AdapterDataObserver adapterDataObserver;
+
+ public static FileViewerFragment newInstance() {
+ FileViewerFragment f = new FileViewerFragment();
+ Bundle b = new Bundle();
+ f.setArguments(b);
+
+ return f;
+ }
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ observer.startWatching();
+ }
+
+ @Override
+ public void onDestroy() {
+ observer.stopWatching();
+ super.onDestroy();
+ }
+
+ @Override
+ public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container,
+ @Nullable Bundle savedInstanceState) {
+ View v = inflater.inflate(R.layout.fragment_file_viewer, container, false);
+
+ mRecyclerView = v.findViewById(R.id.recyclerView);
+ mTextView = v.findViewById(R.id.noRecordView);
+
+ mRecyclerView.setHasFixedSize(true);
+ //newest to oldest order (database stores from oldest to newest)
+ final LinearLayoutManager llm = new LinearLayoutManager(
+ getActivity(), RecyclerView.VERTICAL, true);
+ llm.setStackFromEnd(true);
+
+ mRecyclerView.setLayoutManager(llm);
+ mRecyclerView.setItemAnimator(new DefaultItemAnimator());
+
+ mFileViewerAdapter = new FileViewerAdapter(getActivity(), llm);
+ mRecyclerView.setAdapter(mFileViewerAdapter);
+ changeVisibilityRecycleView();
+ adapterDataObserver = new RecyclerView.AdapterDataObserver() {
+
+ @Override
+ public void onItemRangeInserted(int positionStart, int itemCount) {
+ changeVisibilityRecycleView();
+ super.onItemRangeInserted(positionStart, itemCount);
+ }
+
+ @Override
+ public void onItemRangeRemoved(int positionStart, int itemCount) {
+ changeVisibilityRecycleView();
+ super.onItemRangeRemoved(positionStart, itemCount);
+ }
+ };
+
+ mFileViewerAdapter.registerAdapterDataObserver(adapterDataObserver);
+ return v;
+ }
+
+ /**
+ * Change visibility of RecycleView if no item is present(from {@link FileViewerAdapter})
+ * and show TextView with information about no available item. If any item is present,
+ * visibility for RecycleView is set to visible and for TextView to gone.
+ */
+ private void changeVisibilityRecycleView() {
+ if (mFileViewerAdapter.getItemCount() == 0) {
+ mRecyclerView.setVisibility(View.GONE);
+ mTextView.setVisibility(View.VISIBLE);
+ } else if(mRecyclerView.getVisibility() != View.VISIBLE) {
+ mRecyclerView.setVisibility(View.VISIBLE);
+ mTextView.setVisibility(View.GONE);
+ }
+ }
+
+ @Override
+ public void onDestroyView() {
+ super.onDestroyView();
+ mFileViewerAdapter.unregisterAdapterDataObserver(adapterDataObserver);
+ mFileViewerAdapter = null;
+ }
+
+ private final FileObserver observer =
+ new FileObserver(Paths.combine(
+ Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MUSIC),
+ Paths.SOUND_RECORDER_FOLDER)) {
+ // set up a file observer to watch this directory on sd card
+ @Override
+ public void onEvent(int event, String file) {
+ if (event == FileObserver.DELETE) {
+ // user deletes a recording file out of the app
+ final String filePath = Paths.combine(
+ Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MUSIC),
+ Paths.SOUND_RECORDER_FOLDER, file);
+ Log.d(LOG_TAG, "File deleted [" + filePath + "]");
+
+ // remove file from database and recyclerview
+ mFileViewerAdapter.removeOutOfApp(filePath);
+ }
+ }
+ };
+}
+
+
+
+
diff --git a/app/src/main/java/by/naxa/soundrecorder/fragments/LicensesFragment.java b/app/src/main/java/by/naxa/soundrecorder/fragments/LicensesFragment.java
new file mode 100644
index 00000000..19968c1b
--- /dev/null
+++ b/app/src/main/java/by/naxa/soundrecorder/fragments/LicensesFragment.java
@@ -0,0 +1,33 @@
+package by.naxa.soundrecorder.fragments;
+
+import android.app.Dialog;
+import android.os.Bundle;
+import android.view.LayoutInflater;
+import android.view.View;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.appcompat.app.AlertDialog;
+import androidx.appcompat.app.AppCompatDialogFragment;
+import by.naxa.soundrecorder.R;
+
+/**
+ * Created by Daniel on 1/3/2015.
+ */
+public class LicensesFragment extends AppCompatDialogFragment {
+
+ @Override
+ @NonNull
+ public Dialog onCreateDialog(@Nullable Bundle savedInstanceState) {
+ final LayoutInflater dialogInflater = requireActivity().getLayoutInflater();
+ View openSourceLicensesView = dialogInflater.inflate(R.layout.fragment_licenses, null);
+
+ final AlertDialog.Builder dialogBuilder = new AlertDialog.Builder(requireActivity());
+ dialogBuilder.setView(openSourceLicensesView)
+ .setTitle((getString(R.string.dialog_title_licenses)))
+ .setNeutralButton(android.R.string.ok, null);
+
+ return dialogBuilder.create();
+ }
+
+}
diff --git a/app/src/main/java/by/naxa/soundrecorder/fragments/PlaybackFragment.java b/app/src/main/java/by/naxa/soundrecorder/fragments/PlaybackFragment.java
new file mode 100644
index 00000000..774c91ce
--- /dev/null
+++ b/app/src/main/java/by/naxa/soundrecorder/fragments/PlaybackFragment.java
@@ -0,0 +1,624 @@
+package by.naxa.soundrecorder.fragments;
+
+import android.Manifest;
+import android.app.Dialog;
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.IntentFilter;
+import android.content.pm.PackageManager;
+import android.graphics.ColorFilter;
+import android.graphics.LightingColorFilter;
+import android.media.AudioAttributes;
+import android.media.AudioManager;
+import android.media.MediaPlayer;
+import android.os.Build;
+import android.os.Bundle;
+import android.os.Handler;
+import android.util.Log;
+import android.view.View;
+import android.view.Window;
+import android.widget.SeekBar;
+import android.widget.TextView;
+
+import com.crashlytics.android.Crashlytics;
+import com.google.android.material.floatingactionbutton.FloatingActionButton;
+
+import java.io.IOException;
+
+import androidx.annotation.NonNull;
+import androidx.appcompat.app.AlertDialog;
+import androidx.appcompat.app.AppCompatDialogFragment;
+import by.naxa.soundrecorder.R;
+import by.naxa.soundrecorder.RecordingItem;
+import by.naxa.soundrecorder.listeners.HeadsetListener;
+import by.naxa.soundrecorder.listeners.OnSingleClickListener;
+import by.naxa.soundrecorder.util.AudioManagerCompat;
+import by.naxa.soundrecorder.util.EventBroadcaster;
+import by.naxa.soundrecorder.util.ScreenLock;
+import by.naxa.soundrecorder.util.TimeUtils;
+import io.fabric.sdk.android.Fabric;
+
+import static android.media.AudioManager.ACTION_AUDIO_BECOMING_NOISY;
+import static android.media.AudioManager.ACTION_HEADSET_PLUG;
+import static androidx.core.content.ContextCompat.checkSelfPermission;
+
+/**
+ * Implementation of a MediaPlayer inside DialogFragment.
+ *
+ * System integration is implemented by handling Audio Focus through {@link AudioManager}
+ * and with a {@link HeadsetListener} -- an implementation of
+ * {@link BroadcastReceiver} that pauses playback when headphones are disconnected.
+ *
+ * Created by Daniel on 1/1/2015.
+ */
+public class PlaybackFragment extends AppCompatDialogFragment {
+
+ private static final String LOG_TAG = "PlaybackFragment";
+ private static final String ARG_ITEM = "recording_item";
+ private static final int MY_PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE = 3;
+
+ private RecordingItem item;
+
+ private Handler mHandler = new Handler();
+ private HeadsetListener mHeadsetListener;
+
+ private MediaPlayer mMediaPlayer = null;
+
+ private SeekBar mSeekBar = null;
+ private FloatingActionButton mPlayButton = null;
+ private TextView mCurrentProgressTextView = null;
+ private TextView mFileLengthTextView = null;
+
+ //stores whether or not the mediaplayer is currently playing audio
+ private volatile boolean isPlaying = false;
+
+ // Stores the length of the file in milliseconds
+ private long itemDurationMs = 0;
+
+ /**
+ * Whether this PlaybackFragment has focus from {@link AudioManager} to play audio
+ * @see #requestAudioFocus()
+ * @see #abandonAudioFocus()
+ */
+ private boolean mFocused = false;
+ /**
+ * Whether playback should continue once {@link AudioManager} returns focus to this PlaybackFragment
+ */
+ private boolean mResumeOnFocusGain = false;
+ /**
+ * The volume scalar to set when {@link AudioManager} causes this PlaybackFragment to duck
+ */
+ private static final float DUCK_VOLUME = 0.2f;
+
+ public PlaybackFragment newInstance(RecordingItem item) {
+ PlaybackFragment f = new PlaybackFragment();
+ Bundle b = new Bundle();
+ b.putParcelable(ARG_ITEM, item);
+ f.setArguments(b);
+
+ return f;
+ }
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ final Bundle args;
+ if (savedInstanceState == null) {
+ // not restart
+ args = getArguments();
+ } else {
+ // restart
+ args = savedInstanceState;
+ }
+ if (args == null) {
+ throw new IllegalArgumentException("Bundle args required");
+ }
+
+ item = args.getParcelable(ARG_ITEM);
+ if (item == null)
+ return;
+
+ itemDurationMs = item.getLength();
+ if (Fabric.isInitialized()) {
+ Crashlytics.setLong("recording_item_duration", itemDurationMs);
+ Crashlytics.setString("recording_item_name", item.getName());
+ Crashlytics.setString("recording_item_file_path", item.getFilePath());
+ Crashlytics.setBool("is_playing", isPlaying);
+ Crashlytics.setBool("holding_audio_focus", mFocused);
+ }
+ }
+
+ @Override
+ public void onActivityCreated(Bundle savedInstanceState) {
+ super.onActivityCreated(savedInstanceState);
+ }
+
+ @NonNull
+ @Override
+ public Dialog onCreateDialog(Bundle savedInstanceState) {
+
+ Dialog dialog = super.onCreateDialog(savedInstanceState);
+
+ final AlertDialog.Builder builder = new AlertDialog.Builder(requireContext());
+ final View view = requireActivity().getLayoutInflater().inflate(R.layout.fragment_media_playback, null);
+
+ final TextView fileNameTextView = view.findViewById(R.id.file_name_text_view);
+ mFileLengthTextView = view.findViewById(R.id.file_length_text_view);
+ mCurrentProgressTextView = view.findViewById(R.id.current_progress_text_view);
+
+ mSeekBar = view.findViewById(R.id.seekbar);
+ ColorFilter filter = new LightingColorFilter
+ (getResources().getColor(R.color.primary), getResources().getColor(R.color.primary));
+ mSeekBar.getProgressDrawable().setColorFilter(filter);
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) {
+ mSeekBar.getThumb().setColorFilter(filter);
+ }
+
+ mSeekBar.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() {
+ @Override
+ public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) {
+ if (mMediaPlayer != null && fromUser) {
+ mMediaPlayer.seekTo(progress);
+ mHandler.removeCallbacks(mRunnable);
+
+ final int currentPosition = mMediaPlayer.getCurrentPosition();
+ mCurrentProgressTextView.setText(TimeUtils.formatDuration(currentPosition));
+ updateSeekBar();
+ } else if (mMediaPlayer == null && fromUser) {
+ prepareMediaPlayerFromPoint(progress);
+ updateSeekBar();
+ }
+ }
+
+ @Override
+ public void onStartTrackingTouch(SeekBar seekBar) {
+ if (mMediaPlayer != null) {
+ // remove message Handler from updating progress bar
+ mHandler.removeCallbacks(mRunnable);
+ }
+ }
+
+ @Override
+ public void onStopTrackingTouch(SeekBar seekBar) {
+ if (mMediaPlayer != null) {
+ mHandler.removeCallbacks(mRunnable);
+ mMediaPlayer.seekTo(seekBar.getProgress());
+
+ final int currentPosition = mMediaPlayer.getCurrentPosition();
+ mCurrentProgressTextView.setText(TimeUtils.formatDuration(currentPosition));
+ updateSeekBar();
+ }
+ }
+ });
+
+ mPlayButton = view.findViewById(R.id.fab_play);
+ mPlayButton.setOnClickListener(new OnSingleClickListener() {
+ @Override
+ public void onSingleClick(View v) {
+ isPlaying = onPlay(isPlaying);
+ if (Fabric.isInitialized()) {
+ Crashlytics.setBool("is_playing", isPlaying);
+ }
+ }
+ });
+
+ fileNameTextView.setText(item.getName());
+ mFileLengthTextView.setText(TimeUtils.formatDuration(itemDurationMs));
+
+ builder.setView(view);
+
+ // request a window without the title
+ final Window window = dialog.getWindow();
+ if (window != null) {
+ window.requestFeature(Window.FEATURE_NO_TITLE);
+ }
+
+ return builder.create();
+ }
+
+ /**
+ * Attach a HeadsetListener to respond to headset events.
+ */
+ private void attachHeadsetListener(Context context) {
+ mHeadsetListener = new HeadsetListener(this);
+ IntentFilter filter = new IntentFilter();
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
+ filter.addAction(ACTION_HEADSET_PLUG);
+ }
+ filter.addAction(ACTION_AUDIO_BECOMING_NOISY);
+ if (context != null) {
+ context.registerReceiver(mHeadsetListener, filter);
+ } else {
+ Log.wtf(LOG_TAG, "attachHeadsetListener(): context is null.");
+ }
+ }
+
+ /**
+ * Detach a HeadsetListener to respond to headset events.
+ */
+ private void detachHeadsetListener() {
+ final Context context = getContext();
+ if (context != null) {
+ context.unregisterReceiver(mHeadsetListener);
+ } else {
+ Log.wtf(LOG_TAG, "detachHeadsetListener(): getContext() returned null.");
+ }
+ mHeadsetListener = null;
+ }
+
+ @Override
+ public void onStart() {
+ super.onStart();
+
+ //set transparent background
+ Window window = getDialog().getWindow();
+ if (window != null) {
+ window.setBackgroundDrawableResource(android.R.color.transparent);
+ }
+
+ //disable buttons from dialog
+ AlertDialog alertDialog = (AlertDialog) getDialog();
+ alertDialog.getButton(Dialog.BUTTON_POSITIVE).setEnabled(false);
+ alertDialog.getButton(Dialog.BUTTON_NEGATIVE).setEnabled(false);
+ alertDialog.getButton(Dialog.BUTTON_NEUTRAL).setEnabled(false);
+ }
+
+ @Override
+ public void onPause() {
+ super.onPause();
+
+ if (mMediaPlayer != null) {
+ stopPlaying();
+ }
+ }
+
+ @Override
+ public void onDestroy() {
+ super.onDestroy();
+
+ if (mMediaPlayer != null) {
+ stopPlaying();
+ }
+ }
+
+ /**
+ * @param context a reference to the newly created Activity after each configuration change.
+ */
+ @Override
+ public void onAttach(Context context) {
+ super.onAttach(context);
+ if (mHeadsetListener == null) {
+ attachHeadsetListener(context);
+ }
+ }
+
+ /**
+ * Set the MediaPlayer and Headset listener to null so we don't accidentally
+ * leak the Activity instance.
+ */
+ @Override
+ public void onDetach() {
+ super.onDetach();
+ if (mHeadsetListener != null) {
+ detachHeadsetListener();
+ }
+ }
+
+ public void tapStartButton() {
+ isPlaying = onPlay(false);
+ if (Fabric.isInitialized()) {
+ Crashlytics.setBool("is_playing", isPlaying);
+ }
+ }
+
+ public void tapStopButton() {
+ isPlaying = onPlay(true);
+ if (Fabric.isInitialized()) {
+ Crashlytics.setBool("is_playing", isPlaying);
+ }
+ }
+
+ // Play start/stop
+ private boolean onPlay(boolean isPlaying) {
+ if (!isPlaying) {
+ if (getContext() == null) {
+ // PlaybackFragment is not attached to a Context
+ return isPlaying;
+ }
+
+ if (checkSelfPermission(getContext(), Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) {
+ // Permission is not granted -> request the permission
+ this.requestPermissions(
+ new String[]{Manifest.permission.READ_EXTERNAL_STORAGE},
+ MY_PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE);
+ return isPlaying;
+ }
+ startOrResumePlaying();
+ } else {
+ //pause the MediaPlayer
+ pausePlaying();
+ abandonAudioFocus();
+ }
+ return !isPlaying;
+ }
+
+ private void startOrResumePlaying() {
+ if (mMediaPlayer == null) {
+ // if currently MediaPlayer is not playing audio
+ startPlaying(); // from the beginning
+ } else {
+ resumePlaying();
+ }
+ }
+
+ private void startPlaying() {
+ mPlayButton.setImageResource(R.drawable.ic_media_pause);
+ mMediaPlayer = new MediaPlayer();
+
+ try {
+ mMediaPlayer.setDataSource(item.getFilePath());
+ mMediaPlayer.setAudioStreamType(AudioManager.STREAM_MUSIC);
+ mMediaPlayer.prepare();
+ mSeekBar.setMax(mMediaPlayer.getDuration());
+ requestAudioFocus();
+
+ mMediaPlayer.setOnPreparedListener(new MediaPlayer.OnPreparedListener() {
+ @Override
+ public void onPrepared(MediaPlayer mp) {
+ mMediaPlayer.start();
+ }
+ });
+ } catch (IOException e) {
+ if (Fabric.isInitialized()) Crashlytics.logException(e);
+ Log.e(LOG_TAG, "prepare() failed");
+ EventBroadcaster.send(getContext(), R.string.error_prepare_playback);
+ }
+
+ mMediaPlayer.setOnCompletionListener(new MediaPlayer.OnCompletionListener() {
+ @Override
+ public void onCompletion(MediaPlayer mp) {
+ stopPlaying();
+ }
+ });
+
+ updateSeekBar();
+ ScreenLock.keepScreenOn(getActivity());
+ }
+
+ private void prepareMediaPlayerFromPoint(int progress) {
+ //set mediaPlayer to start from middle of the audio file
+
+ mMediaPlayer = new MediaPlayer();
+
+ try {
+ mMediaPlayer.setDataSource(item.getFilePath());
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
+ final AudioAttributes attributes = new AudioAttributes.Builder()
+ .setUsage(AudioAttributes.USAGE_MEDIA)
+ .setContentType(AudioAttributes.CONTENT_TYPE_UNKNOWN)
+ .build();
+ mMediaPlayer.setAudioAttributes(attributes);
+ } else {
+ mMediaPlayer.setAudioStreamType(AudioManager.STREAM_MUSIC);
+ }
+ mMediaPlayer.prepare();
+ mSeekBar.setMax(mMediaPlayer.getDuration());
+ mMediaPlayer.seekTo(progress);
+ requestAudioFocus();
+
+ mMediaPlayer.setOnCompletionListener(new MediaPlayer.OnCompletionListener() {
+ @Override
+ public void onCompletion(MediaPlayer mp) {
+ stopPlaying();
+ }
+ });
+
+ } catch (IOException e) {
+ if (Fabric.isInitialized()) Crashlytics.logException(e);
+ Log.e(LOG_TAG, "prepare() failed");
+ }
+
+ ScreenLock.keepScreenOn(getActivity());
+ }
+
+ /**
+ * Pauses audio playback
+ */
+ private void pausePlaying() {
+ Log.i(LOG_TAG, "pausePlaying(), isPlaying = " + isPlaying);
+ mResumeOnFocusGain = false;
+ if (!isPlaying)
+ return;
+
+ mPlayButton.setImageResource(R.drawable.ic_media_play);
+ mHandler.removeCallbacks(mRunnable);
+ if (mMediaPlayer != null) {
+ mMediaPlayer.pause();
+ } else {
+ Log.wtf(LOG_TAG, "mMediaPlayer is null");
+ }
+ }
+
+ /**
+ * Resumes audio playback
+ */
+ private void resumePlaying() {
+ Log.i(LOG_TAG, "resumePlaying(), isPlaying = " + isPlaying);
+ if (isPlaying)
+ return;
+
+ mPlayButton.setImageResource(R.drawable.ic_media_pause);
+ mHandler.removeCallbacks(mRunnable);
+ if (mMediaPlayer != null) {
+ if (requestAudioFocus())
+ mMediaPlayer.start();
+ } else {
+ Log.wtf(LOG_TAG, "mMediaPlayer is null");
+ }
+ updateSeekBar();
+ }
+
+ /**
+ * Stops audio playback
+ */
+ private void stopPlaying() {
+ Log.i(LOG_TAG, "stopPlaying()");
+ if (mMediaPlayer == null)
+ return;
+
+ mPlayButton.setImageResource(R.drawable.ic_media_play);
+ mHandler.removeCallbacks(mRunnable);
+ mMediaPlayer.stop();
+ mMediaPlayer.reset();
+ mMediaPlayer.release();
+ mMediaPlayer = null;
+
+ abandonAudioFocus();
+
+ isPlaying = false;
+ if (Fabric.isInitialized()) {
+ Crashlytics.setBool("is_playing", isPlaying);
+ }
+
+ mCurrentProgressTextView.setText(mFileLengthTextView.getText());
+ mSeekBar.setProgress(mSeekBar.getMax());
+
+ ScreenLock.allowScreenTurnOff(getActivity());
+ }
+
+ /**
+ * Gain Audio focus from the system if we don't already have it
+ * @return whether we have gained focus (or already had it)
+ */
+ private boolean requestAudioFocus() {
+ if (!mFocused && getContext() != null) {
+ mFocused = AudioManagerCompat.getInstance(getContext())
+ .requestAudioFocus(focusChangeListener,
+ AudioManager.STREAM_MUSIC,
+ AudioManager.AUDIOFOCUS_GAIN);
+ }
+ if (Fabric.isInitialized()) {
+ Crashlytics.setBool("holding_audio_focus", mFocused);
+ Crashlytics.log(Log.INFO, LOG_TAG, mFocused
+ ? "PlaybackFragment gained audio focus."
+ : "PlaybackFragment did not gain audio focus.");
+ }
+ return mFocused;
+ }
+
+ private int abandonAudioFocus() {
+ final int result;
+
+ final Context context = getContext();
+ if (context == null) {
+ Log.wtf(LOG_TAG, "abandonAudioFocus(): getContext() returned null.");
+ result = AudioManager.AUDIOFOCUS_REQUEST_FAILED;
+ } else {
+ result = AudioManagerCompat.getInstance(context).abandonAudioFocus(focusChangeListener);
+ }
+ mFocused = result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED;
+
+ if (Fabric.isInitialized()) {
+ Crashlytics.setBool("holding_audio_focus", mFocused);
+ Crashlytics.log(Log.INFO, LOG_TAG, mFocused
+ ? "PlaybackFragment did not abandon audio focus."
+ : "PlaybackFragment abandoned audio focus.");
+ }
+ return result;
+ }
+
+ private final AudioManager.OnAudioFocusChangeListener focusChangeListener = new AudioManager.OnAudioFocusChangeListener() {
+ @Override
+ public void onAudioFocusChange(int focusChange) {
+ Log.d(LOG_TAG, "AudioManager.OnAudioFocusChangeListener#onAudioFocusChange " + focusChange);
+
+ switch (focusChange) {
+ case AudioManager.AUDIOFOCUS_LOSS:
+ Log.i(LOG_TAG, "AudioManager.AUDIOFOCUS_LOSS: Pausing playback.");
+ mFocused = false;
+ pausePlaying();
+ isPlaying = false;
+ break;
+ case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT:
+ Log.i(LOG_TAG, "AudioManager.AUDIOFOCUS_LOSS_TRANSIENT: Pausing playback.");
+ boolean resume = isPlaying || mResumeOnFocusGain;
+ mFocused = false;
+ pausePlaying();
+ isPlaying = false;
+ mResumeOnFocusGain = resume;
+ break;
+ case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK:
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ Log.i(LOG_TAG, "AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK: Letting system duck.");
+ } else {
+ Log.i(LOG_TAG, "AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK: Ducking.");
+ if (mMediaPlayer != null) {
+ mMediaPlayer.setVolume(DUCK_VOLUME, DUCK_VOLUME);
+ }
+ }
+ break;
+ case AudioManager.AUDIOFOCUS_GAIN:
+ Log.i(LOG_TAG, "AudioManager.AUDIOFOCUS_GAIN: Increasing volume");
+ mMediaPlayer.setVolume(1f, 1f);
+ if (mResumeOnFocusGain) {
+ resumePlaying();
+ isPlaying = true;
+ }
+ mResumeOnFocusGain = false;
+ break;
+ default:
+ Log.i(LOG_TAG, "Ignoring AudioFocus state change");
+ break;
+ }
+ if (Fabric.isInitialized()) {
+ Crashlytics.setBool("holding_audio_focus", mFocused);
+ Crashlytics.setBool("is_playing", isPlaying);
+ }
+ }
+ };
+
+ //updating mSeekBar
+ private Runnable mRunnable = new Runnable() {
+ @Override
+ public void run() {
+ if (mMediaPlayer != null) {
+ int currentPosition = mMediaPlayer.getCurrentPosition();
+ mSeekBar.setProgress(currentPosition);
+ mCurrentProgressTextView.setText(TimeUtils.formatDuration(currentPosition));
+ updateSeekBar();
+ }
+ }
+ };
+
+ private void updateSeekBar() {
+ mHandler.postDelayed(mRunnable, 1000);
+ }
+
+ @Override
+ public void onRequestPermissionsResult(int requestCode,
+ @NonNull String[] permissions,
+ @NonNull int[] grantResults) {
+ switch (requestCode) {
+ case MY_PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE: {
+ // If request is cancelled, the result arrays are empty.
+ if (grantResults.length > 0
+ && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
+ // permission was granted, yay!
+ startOrResumePlaying();
+ } else {
+ EventBroadcaster.send(getContext(),
+ R.string.error_no_permission_granted_for_playback);
+ }
+ break;
+ }
+ }
+ }
+
+ @Override
+ public void onSaveInstanceState(@NonNull Bundle outState) {
+ super.onSaveInstanceState(outState);
+ outState.putParcelable(ARG_ITEM, item);
+ }
+
+}
diff --git a/app/src/main/java/by/naxa/soundrecorder/fragments/RecordFragment.java b/app/src/main/java/by/naxa/soundrecorder/fragments/RecordFragment.java
new file mode 100644
index 00000000..b65c0cff
--- /dev/null
+++ b/app/src/main/java/by/naxa/soundrecorder/fragments/RecordFragment.java
@@ -0,0 +1,455 @@
+package by.naxa.soundrecorder.fragments;
+
+import android.app.Activity;
+import android.content.BroadcastReceiver;
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.content.ServiceConnection;
+import android.content.pm.PackageManager;
+import android.net.Uri;
+import android.os.Build;
+import android.os.Bundle;
+import android.os.Environment;
+import android.os.IBinder;
+import android.os.SystemClock;
+import android.util.Log;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.Chronometer;
+import android.widget.TextView;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.fragment.app.Fragment;
+import androidx.fragment.app.FragmentActivity;
+import androidx.localbroadcastmanager.content.LocalBroadcastManager;
+
+import com.budiyev.android.circularprogressbar.CircularProgressBar;
+import com.crashlytics.android.Crashlytics;
+import com.google.android.material.button.MaterialButton;
+import com.google.android.material.floatingactionbutton.FloatingActionButton;
+
+import java.io.File;
+
+import by.naxa.soundrecorder.R;
+import by.naxa.soundrecorder.RecorderState;
+import by.naxa.soundrecorder.activities.MainActivity;
+import by.naxa.soundrecorder.listeners.OnSingleClickListener;
+import by.naxa.soundrecorder.services.RecordingService;
+import by.naxa.soundrecorder.util.Command;
+import by.naxa.soundrecorder.util.EventBroadcaster;
+import by.naxa.soundrecorder.util.MyIntentBuilder;
+import by.naxa.soundrecorder.util.Paths;
+import by.naxa.soundrecorder.util.PermissionsHelper;
+import by.naxa.soundrecorder.util.ScreenLock;
+
+/**
+ * A simple {@link Fragment} subclass.
+ * Activities that contain this fragment must implement the
+ * to handle interaction events.
+ * Use the {@link RecordFragment#newInstance} factory method to
+ * create an instance of this fragment.
+ */
+public class RecordFragment extends Fragment {
+ private static final String LOG_TAG = RecordFragment.class.getSimpleName();
+ private static final int MY_PERMISSIONS_REQUEST_RECORD_AUDIO = 1;
+ private static final int MY_PERMISSIONS_REQUEST_RECORD_AUDIO_RESUME = 2;
+
+ //Recording controls
+ private FloatingActionButton mRecordButton = null;
+ private MaterialButton mPauseButton = null;
+ private boolean isRecordButtonInState1 = true; // true = record, false = stop
+ private boolean isPauseButtonInState1 = true; // true = pause, false = resume
+
+ // ProgressBar around Chronometer
+ private CircularProgressBar mProgressBar;
+
+ private TextView mRecordingPrompt;
+ private int mRecordPromptCount = 0;
+
+ private Chronometer mChronometer = null;
+ long timeWhenPaused = 0; //stores time when user clicks pause button
+
+ // Defines callbacks for service binding, passed to bindService()
+ private ServiceConnection mConnection;
+ private RecordingService mRecordingService;
+ private BroadcastReceiver mMessageReceiver = null;
+
+ /**
+ * Use this factory method to create a new instance of
+ * this fragment using the provided parameters.
+ *
+ * @return A new instance of fragment Record_Fragment.
+ */
+ public static RecordFragment newInstance() {
+ RecordFragment f = new RecordFragment();
+ Bundle b = new Bundle();
+ f.setArguments(b);
+
+ return f;
+ }
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ mMessageReceiver = new BroadcastReceiver() {
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ final RecorderState newState = (RecorderState) intent.getSerializableExtra(
+ EventBroadcaster.NEW_STATE);
+ if (RecorderState.STOPPED.equals(newState)) {
+ updateUI(newState, SystemClock.elapsedRealtime());
+ // Recorder was successful and started via a request for audio; set result and finish
+ if (intent.getStringExtra(EventBroadcaster.LAST_AUDIO_LOCATION) != null && MainActivity.REQUEST_INTENTS.contains(requireActivity().getIntent().getAction())) {
+
+ getActivity().setResult(Activity.RESULT_OK, new Intent().setData(Uri.fromFile(new File(intent.getStringExtra(EventBroadcaster.LAST_AUDIO_LOCATION)))));
+ getActivity().finish();
+
+ }
+ } else if (RecorderState.RECORDING.equals(newState)) {
+ long chronometerTime = intent.getLongExtra(
+ EventBroadcaster.CHRONOMETER_TIME,
+ SystemClock.elapsedRealtime()
+ );
+ updateUI(newState, chronometerTime);
+ }
+ }
+ };
+ }
+
+ @Override
+ public void onAttach(Context context) {
+ super.onAttach(context);
+ final Intent intent = new Intent(context, RecordingService.class);
+ tryBindService(context, intent);
+ }
+
+ @Override
+ public void onDetach() {
+ super.onDetach();
+ tryUnbindService(getContext());
+ }
+
+ private void tryBindService(Context context, Intent intent) {
+ if (mConnection == null) {
+ mConnection = new ServiceConnection() {
+
+ @Override
+ public void onServiceConnected(ComponentName className, IBinder service) {
+ // We've bound to LocalService, cast the IBinder and get LocalService instance
+ RecordingService.LocalBinder binder = (RecordingService.LocalBinder) service;
+ mRecordingService = binder.getService();
+ Log.i(LOG_TAG, "RecordFragment ServiceConnection#onServiceConnected");
+
+ long chronometerTime = SystemClock.elapsedRealtime() - mRecordingService.getTotalDurationMillis();
+ updateUI(mRecordingService.getState(), chronometerTime);
+ }
+
+ @Override
+ public void onServiceDisconnected(ComponentName arg0) {
+ Log.i(LOG_TAG, "RecordFragment ServiceConnection#onServiceDisconnected");
+ mRecordingService = null;
+ }
+ };
+
+ context.bindService(intent, mConnection, Context.BIND_AUTO_CREATE);
+ }
+ }
+
+ private void tryUnbindService(Context context) {
+ if (context == null) {
+ Log.w(LOG_TAG, "tryUnbindService: context is null");
+ }
+ if (mConnection != null) {
+ context.unbindService(mConnection);
+ mConnection = null;
+ }
+ }
+
+ @Override
+ @Nullable
+ public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container,
+ @Nullable Bundle savedInstanceState) {
+ View recordView = inflater.inflate(R.layout.fragment_record, container, false);
+
+ mChronometer = recordView.findViewById(R.id.chronometer);
+ //update recording prompt text
+ mRecordingPrompt = recordView.findViewById(R.id.recording_status_text);
+
+ mRecordButton = recordView.findViewById(R.id.btnRecord);
+ mRecordButton.setOnClickListener(createRecordButtonClickListener());
+
+ mProgressBar = recordView.findViewById(R.id.recordProgressBar);
+ mPauseButton = recordView.findViewById(R.id.btnPause);
+ mPauseButton.setVisibility(View.GONE); //hide pause button before recording starts
+ mPauseButton.setOnClickListener(createPauseButtonClickListener());
+
+ return recordView;
+ }
+
+ private final Chronometer.OnChronometerTickListener listener = new Chronometer.OnChronometerTickListener() {
+ @Override
+ public void onChronometerTick(Chronometer chronometer) {
+ if (mRecordPromptCount == 0) {
+ mRecordingPrompt.setText(getString(R.string.record_in_progress) + ".");
+ } else if (mRecordPromptCount == 1) {
+ mRecordingPrompt.setText(getString(R.string.record_in_progress) + "..");
+ } else if (mRecordPromptCount == 2) {
+ mRecordingPrompt.setText(getString(R.string.record_in_progress) + "...");
+ mRecordPromptCount = -1;
+ }
+
+ ++mRecordPromptCount;
+ }
+ };
+
+ private View.OnClickListener createPauseButtonClickListener() {
+ return new OnSingleClickListener() {
+ @Override
+ public void onSingleClick(View v) {
+ if (isPauseButtonInState1) {
+ // pause recording
+ mRecordingService.pauseRecording();
+ long chronometerTime = SystemClock.elapsedRealtime() - mRecordingService.getTotalDurationMillis();
+ updateUI(RecorderState.PAUSED, chronometerTime);
+ } else {
+ if (PermissionsHelper.checkAndRequestPermissions(
+ RecordFragment.this,
+ MY_PERMISSIONS_REQUEST_RECORD_AUDIO
+ )) {
+ resumeRecording();
+ }
+ }
+
+ }
+ };
+ }
+
+ private View.OnClickListener createRecordButtonClickListener() {
+ return new OnSingleClickListener() {
+ @Override
+ public void onSingleClick(View v) {
+ if (isRecordButtonInState1) {
+ if (PermissionsHelper.checkAndRequestPermissions(
+ RecordFragment.this, MY_PERMISSIONS_REQUEST_RECORD_AUDIO)) {
+ startRecording();
+ }
+ } else {
+ stopRecording();
+ }
+ }
+ };
+ }
+
+ private Intent getStartServiceIntent() {
+ return MyIntentBuilder
+ .getInstance(requireActivity(), RecordingService.class)
+ .setCommand(Command.START)
+ .build();
+ }
+
+ private Intent getStopServiceIntent() {
+ return MyIntentBuilder
+ .getInstance(requireActivity(), RecordingService.class)
+ .setCommand(Command.STOP)
+ .build();
+ }
+
+ private void updateUI(RecorderState state, long chronometerBaseTime) {
+ if (getActivity() == null || !isAdded()) {
+ Log.i(LOG_TAG, "RecordFragment#updateUI: RecordFragment is not attached to an Activity");
+ return;
+ }
+ Log.i(LOG_TAG, "RecordFragment#updateUI: new state is " + state + ", time is " + chronometerBaseTime + " ms");
+
+ switch (state) {
+ case STOPPED:
+ mRecordButton.show();
+ mRecordButton.setImageResource(R.drawable.ic_mic_white_36dp);
+ mPauseButton.setVisibility(View.GONE);
+ timeWhenPaused = 0;
+ mRecordingPrompt.setText(getString(R.string.record_prompt));
+
+ isPauseButtonInState1 = true;
+ isRecordButtonInState1 = true;
+
+ mChronometer.setOnChronometerTickListener(null);
+ mChronometer.setBase(SystemClock.elapsedRealtime());
+ mChronometer.stop();
+
+ mProgressBar.setIndeterminate(false);
+ break;
+
+ case PREPARING:
+ mRecordingPrompt.setText(getString(R.string.wait));
+ mProgressBar.setIndeterminate(true);
+ break;
+
+ case RECORDING:
+ mRecordButton.setImageResource(R.drawable.ic_media_stop);
+ mPauseButton.setCompoundDrawablesWithIntrinsicBounds
+ (R.drawable.ic_media_pause, 0, 0, 0);
+ mPauseButton.setText(getString(R.string.pause_recording_button).toUpperCase());
+ mPauseButton.setVisibility(View.VISIBLE);
+ mRecordingPrompt.setText(getString(R.string.record_in_progress));
+
+ isPauseButtonInState1 = true;
+ isRecordButtonInState1 = false;
+
+ mChronometer.setBase(chronometerBaseTime);
+ mChronometer.setOnChronometerTickListener(listener);
+ mChronometer.start();
+
+ mProgressBar.setIndeterminate(true);
+ break;
+
+ case PAUSED:
+ mRecordButton.setImageResource(R.drawable.ic_media_stop);
+ mPauseButton.setCompoundDrawablesWithIntrinsicBounds
+ (R.drawable.ic_media_play, 0, 0, 0);
+ mPauseButton.setText(getString(R.string.resume_recording_button).toUpperCase());
+ mPauseButton.setVisibility(View.VISIBLE);
+ mRecordingPrompt.setText(getString(R.string.record_paused));
+
+ isPauseButtonInState1 = false;
+ isRecordButtonInState1 = false;
+
+ mChronometer.setOnChronometerTickListener(null);
+ mChronometer.setBase(chronometerBaseTime);
+ mChronometer.stop();
+
+ mProgressBar.setIndeterminate(false);
+ break;
+ }
+ }
+
+ /**
+ * Stop recording
+ */
+ private void stopRecording() {
+ final FragmentActivity activity = getActivity();
+ if (activity == null) {
+ Log.wtf(LOG_TAG, "RecordFragment failed to stop recording, getActivity() returns null.");
+ return;
+ }
+ activity.startService(getStopServiceIntent());
+ tryUnbindService(activity);
+
+ ScreenLock.allowScreenTurnOff(activity);
+ }
+
+ /**
+ * Start recording
+ */
+ private boolean startRecording() {
+ final File folder = new File(
+ Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MUSIC),
+ Paths.SOUND_RECORDER_FOLDER
+ );
+ if (!folder.exists()) {
+ // a folder for sound recordings doesn't exist -> create the folder
+ boolean ok = Paths.isExternalStorageWritable() && folder.mkdir();
+ if (!ok) {
+ EventBroadcaster.send(getContext(), R.string.error_mkdir);
+ return false;
+ }
+ }
+
+ final FragmentActivity activity = getActivity();
+ if (activity == null) {
+ Log.wtf(LOG_TAG, "RecordFragment#startRecording(): failed to start recording, getActivity() returns null.");
+ return false;
+ }
+
+ // start RecordingService in foreground
+ final Intent intent = getStartServiceIntent();
+ final ComponentName componentName;
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ componentName = activity.startForegroundService(intent);
+ } else {
+ componentName = activity.startService(intent);
+ }
+ tryBindService(activity, intent);
+ ScreenLock.keepScreenOn(activity);
+
+ if (componentName != null) {
+ updateUI(RecorderState.PREPARING, SystemClock.elapsedRealtime());
+ }
+
+ return true;
+ }
+
+ /**
+ * Resume recording
+ */
+ private void resumeRecording() {
+ long chronometerTime = SystemClock.elapsedRealtime() - mRecordingService.getTotalDurationMillis();
+ mRecordingService.startRecording();
+ updateUI(RecorderState.PREPARING, chronometerTime);
+ }
+
+ @Override
+ public void onResume() {
+ super.onResume();
+ LocalBroadcastManager.getInstance(requireContext()).registerReceiver(
+ mMessageReceiver,
+ new IntentFilter(EventBroadcaster.CHANGE_STATE)
+ );
+ }
+
+ @Override
+ public void onPause() {
+ super.onPause();
+ try {
+ LocalBroadcastManager.getInstance(requireContext()).unregisterReceiver(mMessageReceiver);
+ } catch (Exception exc) {
+ Crashlytics.logException(exc);
+ Log.e(LOG_TAG, "Error unregistering MessageReceiver", exc);
+ }
+ }
+
+ @Override
+ public void onRequestPermissionsResult(int requestCode,
+ @NonNull String[] permissions,
+ @NonNull int[] grantResults) {
+ switch (requestCode) {
+ case MY_PERMISSIONS_REQUEST_RECORD_AUDIO: {
+ // If request is cancelled, the result arrays are empty.
+ if (allPermissionsGranted(grantResults)) {
+ // permissions were granted, yay!
+ startRecording();
+ } else {
+ EventBroadcaster.send(getContext(), R.string.error_no_permission_granted_record);
+ }
+ break;
+ }
+
+ case MY_PERMISSIONS_REQUEST_RECORD_AUDIO_RESUME: {
+ // If request is cancelled, the result arrays are empty.
+ if (allPermissionsGranted(grantResults)) {
+ // permission was granted, yay!
+ resumeRecording();
+ } else {
+ EventBroadcaster.send(getContext(), R.string.error_no_permission_granted_record);
+ }
+ break;
+ }
+ }
+ }
+
+ private boolean allPermissionsGranted(int[] grantResults) {
+ if (grantResults == null || grantResults.length == 0) {
+ return false;
+ }
+
+ boolean ok = true;
+ for (int grantResult : grantResults) {
+ ok &= (grantResult == PackageManager.PERMISSION_GRANTED);
+ }
+ return ok;
+ }
+
+}
diff --git a/app/src/main/java/by/naxa/soundrecorder/fragments/SettingsFragment.java b/app/src/main/java/by/naxa/soundrecorder/fragments/SettingsFragment.java
new file mode 100644
index 00000000..993aa170
--- /dev/null
+++ b/app/src/main/java/by/naxa/soundrecorder/fragments/SettingsFragment.java
@@ -0,0 +1,80 @@
+package by.naxa.soundrecorder.fragments;
+
+import android.os.Bundle;
+import android.preference.CheckBoxPreference;
+import android.preference.Preference;
+import android.preference.PreferenceFragment;
+import android.preference.SwitchPreference;
+import android.widget.Toast;
+
+import androidx.annotation.Nullable;
+import androidx.appcompat.app.AppCompatDelegate;
+
+import by.naxa.soundrecorder.BuildConfig;
+import by.naxa.soundrecorder.R;
+import by.naxa.soundrecorder.SoundRecorderApplication;
+import by.naxa.soundrecorder.activities.SettingsActivity;
+import by.naxa.soundrecorder.util.MySharedPreferences;
+
+/**
+ * This fragment shows general preferences.
+ * Created by Daniel on 5/22/2017.
+ */
+public class SettingsFragment extends PreferenceFragment {
+
+ @Override
+ public void onCreate(@Nullable Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ addPreferencesFromResource(R.xml.preferences);
+
+ final CheckBoxPreference highQualityPref = (CheckBoxPreference) findPreference(
+ getResources().getString(R.string.pref_high_quality_key));
+ highQualityPref.setChecked(MySharedPreferences.getPrefHighQuality(getActivity()));
+ highQualityPref.setOnPreferenceChangeListener(new Preference.OnPreferenceChangeListener() {
+ @Override
+ public boolean onPreferenceChange(Preference preference, Object newValue) {
+ MySharedPreferences.setPrefHighQuality(getActivity(), (boolean) newValue);
+ return true;
+ }
+ });
+
+ final Preference aboutPref = findPreference("pref_about");
+ aboutPref.setSummary(getString(R.string.pref_about_desc, BuildConfig.VERSION_NAME));
+
+ final Preference licensesPref = findPreference("pref_licenses");
+ licensesPref.setOnPreferenceClickListener(new Preference.OnPreferenceClickListener() {
+ @Override
+ public boolean onPreferenceClick(Preference preference) {
+ new LicensesFragment().show(
+ ((SettingsActivity) getActivity()).getSupportFragmentManager()
+ .beginTransaction(), "dialog_licenses");
+ return true;
+ }
+ });
+
+ final SwitchPreference darkModePref = (SwitchPreference) findPreference(
+ getResources().getString(R.string.pref_dark_mode_key));
+
+ if (AppCompatDelegate.getDefaultNightMode() == AppCompatDelegate.MODE_NIGHT_YES) {
+ darkModePref.setChecked(true);
+ }
+
+ darkModePref.setOnPreferenceChangeListener(new Preference.OnPreferenceChangeListener() {
+ @Override
+ public boolean onPreferenceChange(Preference preference, Object newValue) {
+ if(darkModePref.isChecked()) {
+ SoundRecorderApplication.getInstance().setIsNightModeEnabled(false);
+ Toast.makeText(getActivity(), "Dark Mode is OFF", Toast.LENGTH_SHORT).show();
+ darkModePref.setChecked(false);
+ getActivity().finish();
+ } else {
+ SoundRecorderApplication.getInstance().setIsNightModeEnabled(true);
+ Toast.makeText(getActivity(), "Dark Mode is ON", Toast.LENGTH_SHORT).show();
+ darkModePref.setChecked(true);
+ getActivity().finish();
+ }
+ return false;
+ }
+ });
+ }
+}
diff --git a/app/src/main/java/by/naxa/soundrecorder/listeners/HeadsetListener.java b/app/src/main/java/by/naxa/soundrecorder/listeners/HeadsetListener.java
new file mode 100644
index 00000000..a5815596
--- /dev/null
+++ b/app/src/main/java/by/naxa/soundrecorder/listeners/HeadsetListener.java
@@ -0,0 +1,61 @@
+package by.naxa.soundrecorder.listeners;
+
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.util.Log;
+
+import com.crashlytics.android.Crashlytics;
+
+import by.naxa.soundrecorder.fragments.PlaybackFragment;
+import io.fabric.sdk.android.Fabric;
+
+import static android.content.Intent.ACTION_HEADSET_PLUG;
+import static android.media.AudioManager.ACTION_AUDIO_BECOMING_NOISY;
+
+/**
+ * Receives headset connect and disconnect intents so that playback
+ * may be paused when headset (earphone, headphone, or wireless speaker)
+ * is disconnected or when audio becomes too noisy.
+ */
+public class HeadsetListener extends BroadcastReceiver {
+
+ private static final String LOG_TAG = HeadsetListener.class.getName();
+ private PlaybackFragment mInstance;
+
+ boolean shouldResumeOnHeadphonesConnect = false;
+
+ public HeadsetListener(PlaybackFragment instance) {
+ mInstance = instance;
+ }
+
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ if (isInitialStickyBroadcast()) return;
+
+ Log.i(LOG_TAG, "onReceive: " + intent.toString());
+
+ final boolean plugged, unplugged;
+
+ if (ACTION_HEADSET_PLUG.equals(intent.getAction())) {
+ plugged = intent.getIntExtra("state", -1) == 1;
+ unplugged = intent.getIntExtra("state", -1) == 0;
+ } else {
+ plugged = unplugged = false;
+ }
+
+ final boolean becomingNoisy = ACTION_AUDIO_BECOMING_NOISY.equals(intent.getAction());
+
+ if (Fabric.isInitialized()) {
+ Crashlytics.setBool("plugged", plugged);
+ Crashlytics.setBool("unplugged", unplugged);
+ Crashlytics.setBool("becoming_noisy", becomingNoisy);
+ }
+
+ if (unplugged || becomingNoisy) {
+ mInstance.tapStopButton();
+ } else if (plugged && shouldResumeOnHeadphonesConnect) {
+ mInstance.tapStartButton();
+ }
+ }
+}
diff --git a/app/src/main/java/com/danielkim/soundrecorder/listeners/OnDatabaseChangedListener.java b/app/src/main/java/by/naxa/soundrecorder/listeners/OnDatabaseChangedListener.java
similarity index 81%
rename from app/src/main/java/com/danielkim/soundrecorder/listeners/OnDatabaseChangedListener.java
rename to app/src/main/java/by/naxa/soundrecorder/listeners/OnDatabaseChangedListener.java
index 8600190e..d17c96cc 100644
--- a/app/src/main/java/com/danielkim/soundrecorder/listeners/OnDatabaseChangedListener.java
+++ b/app/src/main/java/by/naxa/soundrecorder/listeners/OnDatabaseChangedListener.java
@@ -1,4 +1,4 @@
-package com.danielkim.soundrecorder.listeners;
+package by.naxa.soundrecorder.listeners;
/**
* Created by Daniel on 1/3/2015.
diff --git a/app/src/main/java/by/naxa/soundrecorder/listeners/OnSingleClickListener.java b/app/src/main/java/by/naxa/soundrecorder/listeners/OnSingleClickListener.java
new file mode 100644
index 00000000..6e2183cb
--- /dev/null
+++ b/app/src/main/java/by/naxa/soundrecorder/listeners/OnSingleClickListener.java
@@ -0,0 +1,44 @@
+package by.naxa.soundrecorder.listeners;
+
+import android.os.SystemClock;
+import android.view.View;
+
+/**
+ * Mis-clicking prevention, using threshold of 600 ms
+ * Source: https://stackoverflow.com/a/20672997/1429387
+ *
+ * 处理快速在某个控件上双击2次(或多次)会导致onClick被触发2次(或多次)的问题
+ * 通过判断2次click事件的时间间隔进行过滤
+ *
+ * 子类通过实现{@link #onSingleClick}响应click事件
+ */
+public abstract class OnSingleClickListener implements View.OnClickListener {
+ /**
+ * 最短click事件的时间间隔
+ */
+ private static final long MIN_CLICK_INTERVAL = 600;
+ /**
+ * 上次click的时间
+ */
+ private long mLastClickTime;
+
+ /**
+ * click响应函数
+ *
+ * @param v The view that was clicked.
+ */
+ public abstract void onSingleClick(View v);
+
+ @Override
+ public final void onClick(View v) {
+ long currentClickTime = SystemClock.uptimeMillis();
+ long elapsedTime = currentClickTime - mLastClickTime;
+
+ if (elapsedTime <= MIN_CLICK_INTERVAL)
+ return;
+ mLastClickTime = currentClickTime;
+
+ onSingleClick(v);
+ }
+
+}
diff --git a/app/src/main/java/by/naxa/soundrecorder/services/RecordingService.java b/app/src/main/java/by/naxa/soundrecorder/services/RecordingService.java
new file mode 100644
index 00000000..73447df0
--- /dev/null
+++ b/app/src/main/java/by/naxa/soundrecorder/services/RecordingService.java
@@ -0,0 +1,410 @@
+package by.naxa.soundrecorder.services;
+
+import android.app.Service;
+import android.content.Intent;
+import android.media.MediaRecorder;
+import android.os.Binder;
+import android.os.Environment;
+import android.os.IBinder;
+import android.os.SystemClock;
+import android.util.Log;
+import android.widget.Toast;
+
+import androidx.annotation.Nullable;
+
+import com.coremedia.iso.boxes.Container;
+import com.crashlytics.android.Crashlytics;
+import com.googlecode.mp4parser.authoring.Movie;
+import com.googlecode.mp4parser.authoring.Track;
+import com.googlecode.mp4parser.authoring.builder.DefaultMp4Builder;
+import com.googlecode.mp4parser.authoring.container.mp4.MovieCreator;
+import com.googlecode.mp4parser.authoring.tracks.AppendTrack;
+
+import java.io.File;
+import java.io.FileNotFoundException;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.nio.channels.FileChannel;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.NoSuchElementException;
+
+import by.naxa.soundrecorder.DBHelper;
+import by.naxa.soundrecorder.R;
+import by.naxa.soundrecorder.RecorderState;
+import by.naxa.soundrecorder.util.Command;
+import by.naxa.soundrecorder.util.EventBroadcaster;
+import by.naxa.soundrecorder.util.MyIntentBuilder;
+import by.naxa.soundrecorder.util.MySharedPreferences;
+import by.naxa.soundrecorder.util.NotificationCompatPie;
+import by.naxa.soundrecorder.util.Paths;
+import io.fabric.sdk.android.Fabric;
+
+/**
+ * Created by Daniel on 12/28/2014.
+ */
+public class RecordingService extends Service {
+
+ private static final String LOG_TAG = "RecordingService";
+
+ private String mFileName = null;
+ private String mFilePath = null;
+
+ private MediaRecorder mRecorder = null;
+
+ private DBHelper mDatabase;
+
+ private long mStartingTimeMillis = 0;
+ private long mElapsedMillis = 0;
+
+ private volatile RecorderState state = RecorderState.STOPPED;
+ private int tempFileCount = 0;
+
+ private ArrayList filesPaused = new ArrayList<>();
+ private ArrayList pauseDurations = new ArrayList<>();
+
+ // Binder given to clients
+ private final IBinder mBinder = new LocalBinder();
+
+
+ @Override
+ public IBinder onBind(Intent intent) {
+ return mBinder;
+ }
+
+ @Override
+ public void onCreate() {
+ super.onCreate();
+ mDatabase = new DBHelper(getApplicationContext());
+ if (Fabric.isInitialized())
+ Crashlytics.setString("recorder_state", state.toString());
+ }
+
+ @Override
+ public int onStartCommand(Intent intent, int flags, int startId) {
+ boolean containsCommand = MyIntentBuilder.containsCommand(intent);
+ Log.d(LOG_TAG, String.format(
+ "Service in [%s] state. cmdId: [%s]. startId: [%d]",
+ state,
+ containsCommand ? MyIntentBuilder.getCommand(intent) : "N/A",
+ startId));
+ routeIntentToCommand(intent);
+
+ // We want this service to continue running until it is explicitly stopped, so return sticky
+ return START_STICKY;
+ }
+
+ private void routeIntentToCommand(@Nullable Intent intent) {
+ if (intent != null) {
+ // process command
+ if (MyIntentBuilder.containsCommand(intent)) {
+ processCommand(MyIntentBuilder.getCommand(intent));
+ }
+ // process message
+ if (MyIntentBuilder.containsMessage(intent)) {
+ processMessage(MyIntentBuilder.getMessage(intent));
+ }
+ }
+ }
+
+ private void processMessage(String message) {
+ try {
+ Log.d(LOG_TAG, String.format("doMessage: message from client: '%s'", message));
+ // TODO
+ } catch (Exception e) {
+ Log.e(LOG_TAG, "processMessage: exception", e);
+ }
+ }
+
+ private void processCommand(@Command int command) {
+ try {
+ switch (command) {
+ case Command.START:
+ startRecording();
+ break;
+ case Command.PAUSE:
+ pauseRecording();
+ break;
+ case Command.STOP:
+ stopService();
+ break;
+ }
+ } catch (Exception e) {
+ Log.e(LOG_TAG, "processCommand: exception", e);
+ }
+ }
+
+ public void stopService() {
+ Log.d(LOG_TAG, "RecordingService#stopService()");
+ stopRecording();
+ stopForeground(true);
+ stopSelf();
+ }
+
+ @Override
+ public void onDestroy() {
+ if (mRecorder != null) {
+ stopRecording();
+ }
+
+ super.onDestroy();
+ }
+
+ public void setFileNameAndPath(boolean isFilePathTemp) {
+ if (isFilePathTemp) {
+ mFileName = getString(R.string.default_file_name) + (++tempFileCount) + "_" + ".tmp";
+ Paths.createDirectory(getExternalCacheDir(), Paths.SOUND_RECORDER_FOLDER);
+ mFilePath = Paths.combine(
+ getExternalCacheDir(),
+ Paths.SOUND_RECORDER_FOLDER, mFileName);
+ } else {
+ int count = 0;
+ File f;
+
+ do {
+ ++count;
+
+ mFileName =
+ getString(R.string.default_file_name) + "_" + (mDatabase.getCount() + count) + ".mp4";
+
+ mFilePath = Paths.combine(
+ Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MUSIC),
+ Paths.SOUND_RECORDER_FOLDER, mFileName);
+
+ f = new File(mFilePath);
+ } while (f.exists() && !f.isDirectory());
+ }
+ }
+
+ /**
+ * Start or resume sound recording.
+ */
+ public void startRecording() {
+ if (state == RecorderState.RECORDING || state == RecorderState.PREPARING)
+ return;
+ changeStateTo(RecorderState.PREPARING);
+
+ boolean isTemporary = true;
+ setFileNameAndPath(isTemporary);
+
+ // Configure the MediaRecorder for a new recording
+ mRecorder = new MediaRecorder();
+ mRecorder.setAudioSource(MediaRecorder.AudioSource.MIC);
+ mRecorder.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4);
+ mRecorder.setOutputFile(mFilePath);
+ mRecorder.setAudioEncoder(MediaRecorder.AudioEncoder.AAC);
+ mRecorder.setAudioChannels(1);
+ if (MySharedPreferences.getPrefHighQuality(this)) {
+ mRecorder.setAudioSamplingRate(44100);
+ mRecorder.setAudioEncodingBitRate(192000);
+ }
+
+ try {
+ final long totalDurationMillis = getTotalDurationMillis();
+ mRecorder.prepare();
+ mRecorder.start();
+ if (state != RecorderState.PAUSED)
+ NotificationCompatPie.createNotification(this);
+ changeStateTo(RecorderState.RECORDING);
+ Toast.makeText(this, R.string.toast_recording_start, Toast.LENGTH_SHORT).show();
+ mStartingTimeMillis = SystemClock.elapsedRealtime();
+ EventBroadcaster.startRecording(this, mStartingTimeMillis - totalDurationMillis);
+ } catch (IOException e) {
+ changeStateTo(RecorderState.STOPPED);
+ EventBroadcaster.stopRecording(this);
+ if (Fabric.isInitialized()) Crashlytics.logException(e);
+ Log.e(LOG_TAG, "prepare() failed", e);
+ EventBroadcaster.send(this, getString(R.string.error_unknown));
+ } catch (IllegalStateException e) {
+ changeStateTo(RecorderState.STOPPED);
+ EventBroadcaster.stopRecording(this);
+ if (Fabric.isInitialized()) Crashlytics.logException(e);
+ Log.e(LOG_TAG, "start() failed", e);
+ EventBroadcaster.send(this, getString(R.string.error_mic_is_busy));
+ }
+ }
+
+ public void pauseRecording() {
+ if (state != RecorderState.RECORDING)
+ return;
+ changeStateTo(RecorderState.PREPARING);
+
+ try {
+ mElapsedMillis = (SystemClock.elapsedRealtime() - mStartingTimeMillis);
+ pauseDurations.add(mElapsedMillis);
+ mRecorder.stop();
+ changeStateTo(RecorderState.PAUSED);
+ Toast.makeText(this, getString(R.string.toast_recording_paused), Toast.LENGTH_LONG).show();
+
+ filesPaused.add(mFilePath);
+ } catch (IllegalStateException exc) {
+ changeStateTo(RecorderState.RECORDING);
+ Crashlytics.logException(exc);
+ Log.e(LOG_TAG, "stop() failed", exc);
+ }
+ }
+
+ public void stopRecording() {
+ if (state == RecorderState.STOPPED) {
+ Log.wtf(LOG_TAG, "stopRecording: already STOPPED.");
+ return;
+ }
+ if (state == RecorderState.PREPARING)
+ return;
+ final RecorderState stateBefore = state;
+ changeStateTo(RecorderState.PREPARING);
+ if (stateBefore == RecorderState.RECORDING)
+ filesPaused.add(mFilePath);
+
+ boolean isTemporary = false;
+ setFileNameAndPath(isTemporary);
+ String pathToSend = "";
+ try {
+ if (stateBefore != RecorderState.PAUSED) {
+ mElapsedMillis = (SystemClock.elapsedRealtime() - mStartingTimeMillis);
+ mRecorder.stop();
+ }
+ mRecorder.release();
+ pathToSend = mFilePath;
+ Toast.makeText(this, getString(R.string.toast_recording_finish) + " " + mFilePath, Toast.LENGTH_LONG).show();
+ } catch (RuntimeException exc) {
+ // RuntimeException is thrown when stop() is called immediately after start().
+ // In this case the output file is not properly constructed ans should be deleted.
+ Log.e(LOG_TAG, "RuntimeException: stop() is called immediately after start()", exc);
+ pathToSend = null;
+ Crashlytics.logException(exc);
+ // TODO delete temporary output file
+ } finally {
+ mRecorder = null;
+ changeStateTo(RecorderState.STOPPED);
+ EventBroadcaster.stopRecording(this, pathToSend);
+ }
+
+ if (filesPaused != null && !filesPaused.isEmpty()) {
+ if (makeSingleFile(filesPaused)) {
+ for (long duration : pauseDurations)
+ mElapsedMillis += duration;
+ }
+ }
+
+ try {
+ mDatabase.addRecording(mFileName, mFilePath, mElapsedMillis);
+ } catch (Exception e) {
+ if (Fabric.isInitialized()) Crashlytics.logException(e);
+ Log.e(LOG_TAG, "exception", e);
+ }
+ }
+
+ /**
+ * collect temp generated files because of pause to one target file
+ *
+ * @param filesPaused contains all temp files due to pause
+ */
+ private boolean makeSingleFile(ArrayList filesPaused) {
+ ArrayList