필자는 아래와 같이 녹음하고, 녹음 파일을 재생할 수 있는 앱을 만들고자 한다.

dependencies {
...
implementation 'androidx.activity:activity:1.2.0'
}
build.gradle 수정 후 오른쪽 상단 위에 Sync now 클릭
위의 모듈은 녹음 및 재생 기능과는 관련이 없고, 뒤로가기 버튼을 눌렀을 때를 감지하기 위해 필요한 모듈이다.
녹음 및 재생 기능을 위해서는 마이크 권한, 읽기 권한, 쓰기 권한을 부여해주어야 한다.
아래에서 설명하겠지만, 내부 저장소에 녹음 파일을 저장하는 경우 파일 읽기 및 쓰기 권한은 없어도 된다.
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
...
</manifest>
에뮬레이터에서 마이크를 사용하기 위해서 host(컴퓨터)의 마이크와 연결해주는 과정이 필요하다. 에뮬레이터를 실행할 때 나오는 "..."을 누른 후, 다음의 버튼을 활성화해준다.

만약 위의 사진대로 설정했음에도 마이크가 켜지지 않는다면, 에뮬레이터의 버전이 낮기 때문일 수 있다. 따라서 에뮬레이터의 버전을 업데이트 한 후(ex Pixel 4 -> Pixel 8) 다시 시도해보자.
녹음 이미지, 제목, 파일 길이, 녹음 날짜를 띄울 수 있는 item을 생성한다.
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="10dp"
android:background="#ffffff"
android:elevation="10dp">
<ImageView
android:id="@+id/icon"
android:layout_width="50dp"
android:layout_height="50dp"
android:layout_marginStart="20dp"
android:layout_marginTop="20dp"
android:layout_marginBottom="20dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:srcCompat="@drawable/baseline_mic_none_24" />
<TextView
android:id="@+id/recordTime"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="20dp"
android:layout_marginBottom="20dp"
android:text="03:00"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintStart_toEndOf="@+id/icon"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="1.0" />
<TextView
android:id="@+id/recordTitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="20dp"
android:layout_marginTop="20dp"
android:text="제목"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintStart_toEndOf="@+id/icon"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.0" />
<TextView
android:id="@+id/recordDate"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
android:layout_marginEnd="20dp"
android:text="2024/06/30"
android:textColor="#aaaaaa"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="1.0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.0" />
</androidx.constraintlayout.widget.ConstraintLayout>

RecordItem Adpater Class를 생성한다. RecordItem Adapter Class에는 ViewHolder Class와 RecordItem Class를 추가로 생성해줘야 한다.
RecordItem Class에는 녹음 파일 제목, 파일 길이, 녹음 날짜, 녹음 파일의 실제 경로를 저장할 수 있는 item을 생성한다.
public static class RecordItem {
String name, time, date, filename;
public RecordItem(RecordItem item){
this(item.name, item.time, item.date, item.filename);
}
public RecordItem(String name, String time, String date, String filename) {
this.name = name;
this.time = time;
this.date = date;
this.filename = filename;
}
}
ViewHolder Class는 RecyclerView에서 했던 것과 별반 다르지 않다. 다만, 클릭한 녹음 파일을 재생해야 하므로 어댑터에 클릭 속성을 부여해줘야 한다. 클릭 속성을 부여하기 위해, interface 및 listener를 설정해준다.
public class RecordAdapter extends RecyclerView.Adapter<RecordAdapter.ViewHolder>{
...
public interface OnItemClickListener {
void onItemClicked(RecordItem item);
}
public static OnItemClickListener itemClickListener;
public void setOnItemClickListener (OnItemClickListener listener) {
itemClickListener = listener;
}
public static class ViewHolder extends RecyclerView.ViewHolder {
...
public View parentView;
public ViewHolder(View view) {
super(view);
...
parentView = view;
}
public void setItem(RecordItem item) {
...
parentView.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) { itemClickListener.onItemClicked(item); }
});
}
}
...
}
전체 코드는 아래와 같다.
public class RecordAdapter extends RecyclerView.Adapter<RecordAdapter.ViewHolder>{
public static ArrayList<RecordItem> items = new ArrayList<>();
public interface OnItemClickListener {
void onItemClicked(RecordItem item);
}
public static OnItemClickListener itemClickListener;
public void setOnItemClickListener (OnItemClickListener listener) {
itemClickListener = listener;
}
public static class ViewHolder extends RecyclerView.ViewHolder {
public TextView recordTitle, recordTime, recordDate;
public View parentView;
public ViewHolder(View view) {
super(view);
recordTitle = view.findViewById(R.id.recordTitle);
recordTime = view.findViewById(R.id.recordTime);
recordDate = view.findViewById(R.id.recordDate);
parentView = view;
}
public void setItem(RecordItem item) {
recordTitle.setText(item.name);
recordTime.setText(item.time);
recordDate.setText(item.date);
parentView.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) { itemClickListener.onItemClicked(item); }
});
}
}
@Override
public ViewHolder onCreateViewHolder(ViewGroup viewGroup, int viewType) {
View view = LayoutInflater.from(viewGroup.getContext())
.inflate(R.layout.record_item, viewGroup, false);
return new ViewHolder(view);
}
@Override
public void onBindViewHolder(ViewHolder viewHolder, int position) {
RecordItem item = items.get(position);
viewHolder.setItem(item);
}
@Override
public int getItemCount() {
return items.size();
}
public void addItem(RecordItem item) {
items.add(item);
}
public static class RecordItem {
String name, time, date, filename;
public RecordItem(RecordItem item){
this(item.name, item.time, item.date, item.filename);
}
public RecordItem(String name, String time, String date, String filename) {
this.name = name;
this.time = time;
this.date = date;
this.filename = filename;
}
}
}
지금 구현하고자 하는 앱에서는 버튼이 유동적으로 바뀌어야 한다. 그 예시로, 녹음 시작 버튼이 눌릴 경우 녹음 버튼은 비활성화되며, 녹음 일시정지 버튼과 녹음 종료 버튼이 활성화되어야 한다. 이 챕터에서는 이러한 기능들을 구현한다.
처음 띄워지는 화면에서는 녹음 시작 버튼과 파일 재생 버튼이 보여져야 하며, 녹음 일시정지 버튼, 녹음 재개 버튼, 녹음 종료 버튼, 파일 일시정지 버튼은 비활성화 되어야 한다.
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<Button
android:id="@+id/recordbtn"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="new record"
app:layout_constraintBottom_toTopOf="@+id/recyclerView"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView"
android:layout_width="match_parent"
android:layout_height="400dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" />
<Button
android:id="@+id/stopbtn"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="record stop"
android:visibility="invisible"
app:layout_constraintBottom_toTopOf="@+id/recyclerView"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.75"
app:layout_constraintStart_toStartOf="parent" />
<Button
android:id="@+id/resumebtn"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="resume"
android:visibility="invisible"
app:layout_constraintBottom_toTopOf="@+id/recyclerView"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.263"
app:layout_constraintStart_toStartOf="parent" />
<Button
android:id="@+id/pausebtn"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="pause"
android:visibility="invisible"
app:layout_constraintBottom_toTopOf="@+id/recyclerView"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.263"
app:layout_constraintStart_toStartOf="parent" />
<SeekBar
android:id="@+id/seekBar"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="20dp"
android:layout_marginEnd="20dp"
app:layout_constraintBottom_toTopOf="@+id/recyclerView"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.45" />
<ImageView
android:id="@+id/play"
android:layout_width="70dp"
android:layout_height="70dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.498"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.34"
app:srcCompat="@drawable/play" />
<ImageView
android:id="@+id/pause"
android:layout_width="70dp"
android:layout_height="70dp"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="@+id/play"
app:layout_constraintEnd_toEndOf="@+id/play"
app:layout_constraintStart_toStartOf="@+id/play"
app:layout_constraintTop_toTopOf="@+id/play"
app:srcCompat="@drawable/pause" />
<ImageView
android:id="@+id/backward"
android:layout_width="70dp"
android:layout_height="70dp"
app:layout_constraintBottom_toBottomOf="@+id/play"
app:layout_constraintEnd_toStartOf="@+id/play"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="@+id/play"
app:srcCompat="@drawable/movebackward" />
<ImageView
android:id="@+id/forward"
android:layout_width="70dp"
android:layout_height="70dp"
app:layout_constraintBottom_toBottomOf="@+id/play"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/play"
app:layout_constraintTop_toTopOf="@+id/play"
app:srcCompat="@drawable/moveforward" />
<TextView
android:id="@+id/textView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="TextView"
android:textSize="30sp"
app:layout_constraintBottom_toTopOf="@+id/seekBar"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

아래의 기능들을 구현한다.
아래의 기능들을 구현한다.
녹음이 종료되었을 때 사용자가 입력한 제목으로 저장하기 위해서 팝업 화면을 구성한다(PopupActivity). Java 수준에서는 일반적인 Activity와 동일하게 작성하되, Manifest 파일에서 테마를 수정해줘야 한다.
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
...
<application ...>
<activity
android:name=".PopupActivity"
android:theme="@style/Theme.AppCompat.Light.Dialog"
android:exported="false" />
</application>
</manifest>
또한, PopupActivity에서 MainActivity로 데이터를 전송해야 하기 때문에 intent.putExtra()함수를 사용하여 데이터를 전달하고, ActivityResultLauncher를 사용한다. 팝업에 대한 자세한 설명은 블로그에 작성되어있다.
ActivityResultLauncher<Intent> mStartForResult = registerForActivityResult(
new ActivityResultContracts.StartActivityForResult(),
result -> {
if (result.getResultCode() == 0) {
Intent data = result.getData();
if (data == null) {
return;
}
String title = data.getStringExtra("title");
adapter.addItem(new RecordAdapter.RecordItem(title, "03:00", "2024-07-12", "file경로"));
}
});
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/main"
android:layout_width="300dp"
android:layout_height="wrap_content"
tools:context=".PopupActivity">
<TextView
android:id="@+id/popupTitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
android:text="파일 제목"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<EditText
android:id="@+id/popupTitleEdit"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
android:layout_marginEnd="20dp"
android:layout_marginStart="20dp"
android:hint="파일 제목을 입력하세요."
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/popupTitle" />
<Button
android:id="@+id/popupSave"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
android:text="저장"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/popupTitleEdit" />
</androidx.constraintlayout.widget.ConstraintLayout>

public class PopupActivity extends AppCompatActivity {
EditText editText;
Button saveBtn;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_popup);
editText = findViewById(R.id.popupTitleEdit);
saveBtn = findViewById(R.id.popupSave);
saveBtn.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
String title = editText.getText().toString();
if(!title.isEmpty()) {
Intent intent = new Intent();
intent.putExtra("title", title);
setResult(0, intent);
finish();
} else {
Toast.makeText(getApplicationContext(), "제목을 입력해주세요.", Toast.LENGTH_SHORT).show();
}
}
});
getOnBackPressedDispatcher().addCallback(this, new OnBackPressedCallback(true) {
@Override
public void handleOnBackPressed() { }
});
}
@Override
public boolean onTouchEvent(MotionEvent event) {
if(event.getAction() == MotionEvent.ACTION_OUTSIDE) {
return false;
}
return true;
}
}
전체 코드는 아래와 같다.
public class MainActivity extends AppCompatActivity {
RecyclerView recyclerView;
RecordAdapter adapter = new RecordAdapter();
Button recordBtn, stopBtn, resumeBtn, pauseBtn;
ImageView playImg, pauseImg, forwardImg, backwardImg;
TextView textView;
String filename;
SimpleDateFormat sDate = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.KOREA);
SimpleDateFormat sTime = new SimpleDateFormat("mm:ss", Locale.KOREA);
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
recyclerView = findViewById(R.id.recyclerView);
recordBtn = findViewById(R.id.recordbtn);
stopBtn = findViewById(R.id.stopbtn);
resumeBtn = findViewById(R.id.resumebtn);
pauseBtn = findViewById(R.id.pausebtn);
playImg = findViewById(R.id.play);
pauseImg = findViewById(R.id.pause);
forwardImg = findViewById(R.id.forward);
backwardImg = findViewById(R.id.backward);
seekBar = findViewById(R.id.seekBar);
textView = findViewById(R.id.textView);
textView.setText("");
LinearLayoutManager layoutManager = new LinearLayoutManager(this, LinearLayoutManager.VERTICAL, false);
recyclerView.setLayoutManager(layoutManager);
recyclerView.setAdapter(adapter);
adapter.setOnItemClickListener(new RecordAdapter.OnItemClickListener() {
@Override
public void onItemClicked(RecordAdapter.RecordItem item) {
filename = item.filename;
playImg.setVisibility(View.VISIBLE);
pauseImg.setVisibility(View.GONE);
textView.setText(item.name);
}
});
recordBtn.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
recordBtn.setVisibility(View.GONE);
resumeBtn.setVisibility(View.GONE);
stopBtn.setVisibility(View.VISIBLE);
pauseBtn.setVisibility(View.VISIBLE);
}
});
stopBtn.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
recordBtn.setVisibility(View.VISIBLE);
stopBtn.setVisibility(View.GONE);
resumeBtn.setVisibility(View.GONE);
pauseBtn.setVisibility(View.GONE);
Intent intent = new Intent(MainActivity.this, PopupActivity.class);
mStartForResult.launch(intent);
}
});
pauseBtn.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
pauseBtn.setVisibility(View.GONE);
resumeBtn.setVisibility(View.VISIBLE);
}
});
resumeBtn.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
resumeBtn.setVisibility(View.GONE);
pauseBtn.setVisibility(View.VISIBLE);
}
});
playImg.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
playImg.setVisibility(View.INVISIBLE);
pauseImg.setVisibility(View.VISIBLE);
}
});
pauseImg.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
playImg.setVisibility(View.VISIBLE);
pauseImg.setVisibility(View.INVISIBLE);
}
});
forwardImg.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
}
});
backwardImg.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
}
});
}
ActivityResultLauncher<Intent> mStartForResult = registerForActivityResult(
new ActivityResultContracts.StartActivityForResult(),
result -> {
if (result.getResultCode() == 0) {
Intent data = result.getData();
if (data == null) {
return;
}
String title = data.getStringExtra("title");
adapter.addItem(new RecordAdapter.RecordItem(title, "03:00", "2024-07-12", "file경로"));
}
});
}
앱에서 녹음 기능 및 파일 쓰기를 위해 권한이 필요하다. 앱을 시작할 때 권한을 비교하여 권한이 없다면 요청하는 페이지를 띄워야 한다.
if (ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED ||
ContextCompat.checkSelfPermission(this, Manifest.permission.WRITE_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED ||
ContextCompat.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) {
ActivityCompat.requestPermissions(this, new String[]{Manifest.permission.RECORD_AUDIO, Manifest.permission.WRITE_EXTERNAL_STORAGE, Manifest.permission.READ_EXTERNAL_STORAGE}, 0);
}
사용자가 파일 제목을 저장하고, 그 제목을 그대로 파일에 덮어씌우면 문제가 발생한다. 예를 들어, 사용자가 test라는 제목으로 녹음 파일을 저장하고, 후에 다시 test라는 제목으로 녹음 파일을 저장하면 동일한 경로에 같은 파일이 존재하여 파일을 구분할 수 없게 된다.
필자는 이를 방지하기 위해 파일을 저장할 때 사용자가 입력한 제목 외에 시간 정보를 파일명에 포함하여 저장하였다. 이렇게 저장했을 때의 이점은 아래와 같다.
녹음 파일이 언제 생성되었는지 제목만을 통해 알 수 있다. 후에 녹음 생성 시간 기준으로 정렬할 필요가 있을 때 편리하다.
사용자가 입력한 제목을 괄호로 구분지어 코딩을 통해 추출하기 편리하다.

위와 같이 파일명을 정할 경우, 파일명을 파싱(정보 추출)하는 과정이 필요하다. 필자는 파싱 후 어댑터에 추가해주는 함수(parseNameAndAddItem)를 구현하였다. 또한 어댑터의 아이템에 녹음 파일의 길이를 나타내기 위한 함수(getMP3FileLength)도 구현하였다.
public int getMP3FileLength(File file) {
int duration = 0;
try {
MediaPlayer tmpmediaPlayer = new MediaPlayer();
FileInputStream stream = new FileInputStream(file.getAbsolutePath());
tmpmediaPlayer.setDataSource(stream.getFD());
stream.close();
tmpmediaPlayer.prepare();
duration = tmpmediaPlayer.getDuration();
tmpmediaPlayer.release();
} catch (IOException e) {
e.printStackTrace();
}
return duration;
}
private void parseNameAndAddItem(String filename) {
if (!filename.contains(".mp3") || !filename.contains("(") || !filename.contains(")")) {
return;
}
String[] split = filename.split("/");
String name = split[split.length - 1];
String[] split2 = name.split("\\(");
String datetime = split2[0];
String[] split3 = split2[1].split("\\)");
String title = split3[0];
String[] split4 = datetime.split(" ");
String date = split4[0];
String length = sTime.format(new Date(getMP3FileLength(new File(filename))));
adapter.addItem(new RecordAdapter.RecordItem(title, length, date, filename));
adapter.notifyDataSetChanged();
}
이제 파일명을 어떻게 저장할지 결정하였으니, 어느 경로에 저장할지 결정해야 한다. 필자의 경우 외부 cache directory에 저장하였다. 이 경로로 저장했을 때의 장점은 아래와 같다.
시스템상 저장공간이 부족할 경우, 자동으로 cache 데이터를 삭제하는데, 이 때 녹음 파일이 삭제되어 저장공간을 불필요하게 차지하는 것을 방지한다. 대부분의 앱 개발 과정에서 생성된 녹음 파일은 서버에도 저장되기에, 로컬에서 자동으로 삭제되는 것은 문제가 되지 않는다.
내부 cache directory도 있는데, 이는 핸드폰의 File 앱을 통해 접근이 불가하다. 녹음이 제대로 이뤄졌는지 등을 편하게 확인하기 위해서는 외부 cache directory를 사용하는 것이 좋다. (만약 확인이 끝났다면 내부 cache directory에 저장해도 좋다.)
music 폴더 등에 저장하지 않아 사용자의 핸드폰에 불필요한 파일을 생성하지 않는다. (자신이 저장하지 않은 mp3 파일이 music 폴더에 생겼다고 상상해보자. 많이 불편할 것이다.)
먼저 PopupActivity에서 전달받은 title과 시간 정보를 포함한 제목을 저장할 수 있도록 다음과 같은 코드를 사용하였다.
SimpleDateFormat sDate = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.KOREA);
String newFilename = getExternalCacheDir().getAbsolutePath() + "/" + sDate.format(new Date()) + "(" + title + ").mp3";
다음으로 제목으로부터 파싱한 정보를 어댑터에 추가하기 위해 parseNameAndAddItem 함수를 이용하였다. Activity가 실행되면 저장되어있던 파일 또한 띄워줘야 하므로, for문을 사용하였다.
File dir = new File(getExternalCacheDir().getAbsolutePath());
File[] files = dir.listFiles();
for (File file : files) {
parseNameAndAddItem(file.getAbsolutePath());
}
Android Studio에는 MediaRecorder라는 객체가 존재하고, 이를 이용해 녹음하면 된다. 아래의 사진은 MediaRecorder 객체와 관련된 상태 diagram이다.

예를 들어 녹음(Recording) 상태가 되기 위해선, Initial - Initialized - DataSourceConfigured - Prepared - Recording 의 과정을 거쳐야 한다. 즉, 아래의 함수들이 실행되어야 한다.
파일의 확장자는 mp3이고, OutputFormat은 MPEG_4 방식이어서 헷갈릴 수 있는데, MediaRecorder에서는 mp3 녹음을 지원해주지 않기 때문에 MPEG_4를 사용한다. 즉, MPEG_4로 정해진 Format 파일에 이름만 mp3가 붙어있다고 생각하면 된다.
// Initial
mediaRecorder = new MediaRecorder();
// Initial -> Initialized
mediaRecorder.setAudioSource(MediaRecorder.AudioSource.MIC);
// Initialized -> DataSourceConfigured
mediaRecorder.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4);
mediaRecorder.setAudioEncoder(MediaRecorder.AudioEncoder.AAC);
mediaRecorder.setOutputFile(outputFilePath);
// DataSourceConfigured -> Prepared
mediaRecorder.prepare();
// Prepared -> Recording
mediaRecorder.start();
만약 Released상태에서 녹음을 시작하려고 Recording상태로 바꾸는 등의 작업을 한다면, 올바르지 못한 상태로 인식된다. 따라서 IllegalStateException이 발생하고, 비정상적으로 종료된다. 만약 IllegalStateException이 발생했다면 state가 올바르게 바뀌는지 확인해보아야 한다.
앞서 설명한대로, Android Studio에는 녹음이 가능한 MediaRecorder 객체가 있다. 하지만 왜 Recorder 객체를 또 만들어야 하는지 의문이 들 수 있다. MediaRecorder에는 현재 녹음이 진행중인지 상태를 알 수 있는 변수나 함수가 존재하지 않는다.
Recording 에서 새로운 녹음을 시작하기 위해서는 stop() 함수를 호출해야 하는데, 만약 Released 상태에 있다면 IllegalStateException이 발생한다. 따라서 현재 녹음 상태를 저장하는 변수(isRecording)을 MediaRecorder와 같이 관리하기 위해서 Recorder 객체를 새로 만들어주었다.
Recorder 객체를 만들 때 경로를 전달받아 class 변수로 저장해주었다.
public class Recorder {
private MediaRecorder mediaRecorder;
private String outputFilePath;
private boolean isRecording = false;
public Recorder(String outputFilePath) {
this.outputFilePath = outputFilePath;
}
}
녹음을 시작하기 위한 함수(startRecording)를 Recorder 객체 내에 생성한다. 이 때 녹음 중이라면 기존의 녹음을 멈춘 후(stop) 녹음을 시작한다.
녹음을 시작하려면 AudioSource 지정, OutputFormat 지정, AudioEncoder 지정, 파일 경로 지정 후 Prepared상태로 만든 후 녹음을 시작해야 한다. 위의 과정을 모두 포함하면 아래와 같이 코딩할 수 있다. stopRecording()함수는 녹음을 종료하는 함수이고, 아래에서 구현한다.
public void startRecording() {
if (isRecording) {
stopRecording();
}
mediaRecorder = new MediaRecorder();
mediaRecorder.setAudioSource(MediaRecorder.AudioSource.MIC);
mediaRecorder.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4);
mediaRecorder.setAudioEncoder(MediaRecorder.AudioEncoder.AAC);
mediaRecorder.setOutputFile(outputFilePath);
try {
mediaRecorder.prepare();
mediaRecorder.start();
isRecording = true;
} catch (IOException e) {
e.printStackTrace();
}
}
다음으로 녹음을 중지하는 함수(stopRecording)를 구현한다. 녹음을 중지하려면 우선 녹음 상태여야 하고, Recording 상태에서 Initial상태로 만든 후 Released상태로 만들어야 한다. 따라서 isRecording 변수를 활용해 녹음 상태를 먼저 파악하고, stop() 함수와 release() 함수를 활용한다.
public void stopRecording() {
if (isRecording && mediaRecorder != null) {
mediaRecorder.stop();
mediaRecorder.release();
mediaRecorder = null;
isRecording = false;
}
}
다음으로 녹음을 일시정지하는 함수(pauseRecording) 및 재개하는 함수(resumeRecording)를 구현한다. 두 함수 모두 녹음 중에만 실행 가능해야 하므로 isRecording 변수를 활용해 녹음 상태를 먼저 파악한 후 pause() 함수와 resume() 함수를 활용한다.
public void pauseRecording() {
if (isRecording && mediaRecorder != null) {
mediaRecorder.pause();
}
}
public void resumeRecording() {
if (isRecording && mediaRecorder != null) {
mediaRecorder.resume();
}
}
전체 코드는 아래와 같다.
public class Recorder {
private MediaRecorder mediaRecorder;
private String outputFilePath;
private boolean isRecording = false;
public Recorder(String outputFilePath) {
this.outputFilePath = outputFilePath;
}
public void startRecording() {
if (isRecording) {
stopRecording();
}
mediaRecorder = new MediaRecorder();
mediaRecorder.setAudioSource(MediaRecorder.AudioSource.MIC);
mediaRecorder.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4);
mediaRecorder.setAudioEncoder(MediaRecorder.AudioEncoder.AAC);
mediaRecorder.setOutputFile(outputFilePath);
try {
mediaRecorder.prepare();
mediaRecorder.start();
isRecording = true;
} catch (IOException e) {
e.printStackTrace();
}
}
public void stopRecording() {
if (isRecording && mediaRecorder != null) {
mediaRecorder.stop();
mediaRecorder.release();
mediaRecorder = null;
isRecording = false;
}
}
public void pauseRecording() {
if (isRecording && mediaRecorder != null) {
mediaRecorder.pause();
}
}
public void resumeRecording() {
if (isRecording && mediaRecorder != null) {
mediaRecorder.resume();
}
}
}
이제 앞서 생성한 Recorder 객체를 이용해서 녹음 기능을 구현한다. 녹음 기능이 적용된 전체 코드는 아래와 같다.
public class MainActivity extends AppCompatActivity {
RecyclerView recyclerView;
RecordAdapter adapter = new RecordAdapter();
Button recordBtn, stopBtn, resumeBtn, pauseBtn;
ImageView playImg, pauseImg, forwardImg, backwardImg;
TextView textView;
String filename;
SimpleDateFormat sDate = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.KOREA);
SimpleDateFormat sTime = new SimpleDateFormat("mm:ss", Locale.KOREA);
Recorder recorder;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
recyclerView = findViewById(R.id.recyclerView);
recordBtn = findViewById(R.id.recordbtn);
stopBtn = findViewById(R.id.stopbtn);
resumeBtn = findViewById(R.id.resumebtn);
pauseBtn = findViewById(R.id.pausebtn);
playImg = findViewById(R.id.play);
pauseImg = findViewById(R.id.pause);
forwardImg = findViewById(R.id.forward);
backwardImg = findViewById(R.id.backward);
seekBar = findViewById(R.id.seekBar);
textView = findViewById(R.id.textView);
textView.setText("");
LinearLayoutManager layoutManager = new LinearLayoutManager(this, LinearLayoutManager.VERTICAL, false);
recyclerView.setLayoutManager(layoutManager);
recyclerView.setAdapter(adapter);
adapter.setOnItemClickListener(new RecordAdapter.OnItemClickListener() {
@Override
public void onItemClicked(RecordAdapter.RecordItem item) {
filename = item.filename;
playImg.setVisibility(View.VISIBLE);
pauseImg.setVisibility(View.GONE);
textView.setText(item.name);
}
});
recordBtn.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
filename = getExternalCacheDir().getAbsolutePath() + "/" + sDate.format(new Date()) + ".mp3";
recorder = new Recorder(filename);
recorder.startRecording();
recordBtn.setVisibility(View.GONE);
resumeBtn.setVisibility(View.GONE);
stopBtn.setVisibility(View.VISIBLE);
pauseBtn.setVisibility(View.VISIBLE);
}
});
stopBtn.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
recorder.stopRecording();
recordBtn.setVisibility(View.VISIBLE);
stopBtn.setVisibility(View.GONE);
resumeBtn.setVisibility(View.GONE);
pauseBtn.setVisibility(View.GONE);
Intent intent = new Intent(MainActivity.this, PopupActivity.class);
mStartForResult.launch(intent);
}
});
pauseBtn.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
recorder.pauseRecording();
pauseBtn.setVisibility(View.GONE);
resumeBtn.setVisibility(View.VISIBLE);
}
});
resumeBtn.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
recorder.resumeRecording();
resumeBtn.setVisibility(View.GONE);
pauseBtn.setVisibility(View.VISIBLE);
}
});
playImg.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
playImg.setVisibility(View.INVISIBLE);
pauseImg.setVisibility(View.VISIBLE);
}
});
pauseImg.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
playImg.setVisibility(View.VISIBLE);
pauseImg.setVisibility(View.INVISIBLE);
}
});
forwardImg.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
}
});
backwardImg.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
}
});
}
ActivityResultLauncher<Intent> mStartForResult = registerForActivityResult(
new ActivityResultContracts.StartActivityForResult(),
result -> {
if (result.getResultCode() == 0) {
Intent data = result.getData();
if (data == null) {
return;
}
String title = data.getStringExtra("title");
File file = new File(filename);
String newFilename = getExternalCacheDir().getAbsolutePath() + "/" + sDate.format(new Date()) + "(" + title + ").mp3";
file.renameTo(new File(newFilename));
parseNameAndAddItem(newFilename);
}
});
private void parseNameAndAddItem(String filename) {
if (!filename.contains(".mp3") || !filename.contains("(") || !filename.contains(")")) {
return;
}
String[] split = filename.split("/");
String name = split[split.length - 1];
String[] split2 = name.split("\\(");
String datetime = split2[0];
String[] split3 = split2[1].split("\\)");
String title = split3[0];
String[] split4 = datetime.split(" ");
String date = split4[0];
String length = sTime.format(new Date(getMP3FileLength(new File(filename))));
adapter.addItem(new RecordAdapter.RecordItem(title, length, date, filename));
adapter.notifyDataSetChanged();
}
public int getMP3FileLength(File file) {
int duration = 0;
try {
MediaPlayer tmpmediaPlayer = new MediaPlayer();
FileInputStream stream = new FileInputStream(file.getAbsolutePath());
tmpmediaPlayer.setDataSource(stream.getFD());
stream.close();
tmpmediaPlayer.prepare();
duration = tmpmediaPlayer.getDuration();
tmpmediaPlayer.release();
} catch (IOException e) {
e.printStackTrace();
}
return duration;
}
}
Android Studio에는 MediaPlayer라는 객체가 존재하고, 이를 이용해 녹음 파일을 재생할 수 있다. 아래의 사진은 MediaPlayer 객체와 관련된 상태 diagram이다.

예를 들어 재생(Started) 상태가 되기 위해선, Idle - Initialized - Prepared - Started 의 과정을 거쳐야 한다. 즉, 아래의 함수들이 실행되어야 한다.
// Idle
mediaPlayer = new MediaPlayer();
// Idle -> Initialized
FileInputStream stream = new FileInputStream(filename);
mediaPlayer.setDataSource(stream.getFD());
stream.close();
// Initialized -> Prepared
mediaPlayer.prepare();
// Prepared -> Started
mediaPlayer.start();
만약 Stopped 상태에서 파일을 재생하기 위해 Started 상태로 바꾸는 등의 작업을 한다면, 올바르지 못한 상태로 인식된다. 따라서 MediaRecorder와 마찬가지로 IllegalStateException이 발생하고, 비정상적으로 종료된다. 만약 IllegalStateException이 발생했다면 state가 올바르게 바뀌는지 확인해보아야 한다.
Recorder 객체와 마찬가지로, MediaPlayer의 관리를 편하게 하기 위해 Player 객체를 생성한다. 우선, 어떤 파일을 재생할 지 입력받기 위해 파일 경로를 전달받은 후, MediaPlayer를 Started 상태로 만들어주었다. position 변수는 MediaPlayer의 재생 위치를 저장하기 위한 변수이다.
public Player(String filename) {
if (mediaPlayer != null) {
mediaPlayer.reset();
mediaPlayer.release();
mediaPlayer = null;
}
mediaPlayer = new MediaPlayer();
try {
FileInputStream stream = new FileInputStream(filename);
mediaPlayer.setDataSource(stream.getFD());
stream.close();
mediaPlayer.prepare();
position = 0;
} catch (IOException e) {
e.printStackTrace();
}
}
다음으로 파일 재생, 일시정지를 위한 함수들을 코딩한다. 추후에 SeekBar를 이용하여 재생 위치를 정하고, 사용자가 정한 위치에서 파일을 재생하기 위해서 seekTo 함수와 start 함수를 사용하였다. seekTo 함수에 마지막으로 저장된 위치(position)를 인자로 전달하여 position 위치부터 파일을 재생하도록 하였다. 파일이 일시정지될 경우, pause 함수를 사용하여 mediaPlayer를 멈춘 뒤 getCurrentPosition 함수를 사용하여 position을 업데이트 해주었다.
또한, 파일 재생 및 일시정지할 때, mediaPlayer가 null인 경우 버튼을 업데이트(예를 들면 일시정지 버튼을 눌렀을 때 재생 버튼 활성화 하는 등 화면 업데이트)를 하지 않기 위해 true, false를 return해주었다.
boolean playAudio() {
if (mediaPlayer != null) {
mediaPlayer.seekTo(position);
mediaPlayer.start();
return true;
}
return false;
}
boolean pauseAudio() {
if (mediaPlayer != null) {
mediaPlayer.pause();
position = mediaPlayer.getCurrentPosition();
return true;
}
return false;
}
다음으로 재생 파일을 앞으로 혹은 뒤로 건너뛸 수 있도록 함수들을 코딩한다. 현재 MediaPlayer가 탐색하고 있는 위치(position)로부터 앞으로 혹은 뒤로 position을 바꾸고, seekTo 함수를 사용하여 바꾼 position부터 탐색하도록 하였다.
void forwardAudio(int millis) {
if (mediaPlayer != null) {
position = mediaPlayer.getCurrentPosition();
position += millis;
mediaPlayer.seekTo(position);
}
}
void backwardAudio(int millis) {
if (mediaPlayer != null) {
position = mediaPlayer.getCurrentPosition();
position -= millis;
mediaPlayer.seekTo(position);
}
}
다음으로 SeekBar가 바뀜에 따라 MediaPlayer 탐색 위치를 바꿀 수 있는 함수인 updateSeekBar 함수를 구현한다. SeekBar에서 0~100으로 진행과정을 나타내기에, int로 전달받은 progress를 실제 파일의 position으로 바꿔서 저장한 후, seekTo 함수를 이용하여 MediaPlayer를 업데이트 해주었다.
ublic void updateSeekBar(int progress) {
if (mediaPlayer != null) {
position = mediaPlayer.getDuration() * progress / 100;
mediaPlayer.seekTo(position);
}
}
다음으로 MediaPlayer를 삭제하는 함수를 코딩한다. reset 함수와 release 함수를 사용하여 정상적으로 종료 시킨 후, mediaPlayer를 null로 만들어주었다.
void releaseAudio() {
if (mediaPlayer != null) {
mediaPlayer.reset();
mediaPlayer.release();
mediaPlayer = null;
}
}
다른 class에서 MediaPlayer가 재생하고 있는 파일의 총 길이를 알 수 있도록 getLength 함수와 MediaPlayer가 보고 있는 위치를 알 수 있는 getPosition 함수도 구현해주었다.
int getLength() {
return mediaPlayer.getDuration();
}
int getPosition() {
return mediaPlayer.getCurrentPosition();
}
전체적인 코드는 아래와 같다.
public class Player {
private MediaPlayer mediaPlayer;
private int position = 0;
public Player(String filename) {
if (mediaPlayer != null) {
mediaPlayer.reset();
mediaPlayer.release();
mediaPlayer = null;
}
mediaPlayer = new MediaPlayer();
try {
FileInputStream stream = new FileInputStream(filename);
mediaPlayer.setDataSource(stream.getFD());
stream.close();
mediaPlayer.prepare();
position = 0;
} catch (IOException e) {
e.printStackTrace();
}
}
public void updateSeekBar(int progress) {
if (mediaPlayer != null) {
position = mediaPlayer.getDuration() * progress / 100;
mediaPlayer.seekTo(position);
}
}
boolean playAudio() {
if (mediaPlayer != null) {
mediaPlayer.seekTo(position);
mediaPlayer.start();
return true;
}
return false;
}
boolean pauseAudio() {
if (mediaPlayer != null) {
mediaPlayer.pause();
position = mediaPlayer.getCurrentPosition();
return true;
}
return false;
}
void forwardAudio(int millis) {
if (mediaPlayer != null) {
position = mediaPlayer.getCurrentPosition();
position += millis;
mediaPlayer.seekTo(position);
}
}
void backwardAudio(int millis) {
if (mediaPlayer != null) {
position = mediaPlayer.getCurrentPosition();
position -= millis;
mediaPlayer.seekTo(position);
}
}
void releaseAudio() {
if (mediaPlayer != null) {
mediaPlayer.reset();
mediaPlayer.release();
mediaPlayer = null;
}
}
int getLength() {
return mediaPlayer.getDuration();
}
int getPosition() {
return mediaPlayer.getCurrentPosition();
}
}
앞서 만든 Player 객체를 이용하여 녹음된 파일을 재생한다. 우선, 어댑터의 item을 클릭했을 때 item에 저장된 파일 경로 정보를 이용하여 새로운 player 객체를 할당한다.
adapter.setOnItemClickListener(new RecordAdapter.OnItemClickListener() {
@Override
public void onItemClicked(RecordAdapter.RecordItem item) {
filename = item.filename;
playImg.setVisibility(View.VISIBLE);
pauseImg.setVisibility(View.GONE);
textView.setText(item.name);
player = new Player(filename);
}
});
다음으로, 재생 버튼 및 일시정지 버튼을 눌렀을 때 player의 playAudio 함수와 pauseAudio 함수를 실행한다. 앞서 설명했듯이, player 객체 내부의 mediaPlayer가 null일 경우 버튼 활성화를 막기 위하여 if문을 활용하였다.
playImg.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
if (player.playAudio()) {
playImg.setVisibility(View.INVISIBLE);
pauseImg.setVisibility(View.VISIBLE);
}
}
});
pauseImg.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
if (player.pauseAudio()) {
playImg.setVisibility(View.VISIBLE);
pauseImg.setVisibility(View.INVISIBLE);
}
}
});
마지막으로, 앞으로가기 버튼과 뒤로가기 버튼을 눌렀을 때, seekBar를 사용자가 움직였을 때 player가 탐색하는 위치를 바꿔주기 위해 player 객체에서 만든 forwardAudio, backwardAudio 함수를 이용하였다. 필자의 경우 5초 이동을 위해 인자로 5000(millis)를 전달하였다.
seekBar.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() {
@Override
public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) {
if (fromUser) {
player.updateSeekBar(progress);
}
}
@Override
public void onStartTrackingTouch(SeekBar seekBar) { }
@Override
public void onStopTrackingTouch(SeekBar seekBar) { }
});
playImg.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
if (player.playAudio()) {
playImg.setVisibility(View.INVISIBLE);
pauseImg.setVisibility(View.VISIBLE);
}
}
});
pauseImg.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
if (player.pauseAudio()) {
playImg.setVisibility(View.VISIBLE);
pauseImg.setVisibility(View.INVISIBLE);
}
}
});
마지막으로, seekBar를 자동으로 옮겨주는 thread를 생성한다. thread에 대해 간단하게 설명하면, 프로그램이 실행될 때 여러 갈래로 나누어 동시에 작업을 수행하는 것을 의미한다. seekBar를 옮기는 작업은 계속 반복적으로 이뤄지는데, 이로 인해 사용자의 버튼 클릭 입력 등을 받지 못하면 앱이 정상적으로 작동하지 못한다. 따라서 seekBar를 옮기는 작업과 사용자의 입력을 작업을 동시에 수행하기 위해 thread를 생성한다.
thread는 종료되기 전까지(interrupt 되기 전까지)아래의 과정을 무한히 반복한다.
100ms마다 현재 player가 탐색하고 있는 위치를 position 변수에 저장
position 변수에 저장된 값을 0~100 사이의 값으로 변환하여 seekBar에 적용
에러가 발생할 경우 player release한 후 무한 반복문 종료
class MediaThread extends Thread {
@Override
public void run() {
int mediaDuration = player.getLength();
try {
while (!Thread.currentThread().isInterrupted()) {
try {
position = player.getPosition();
} catch (IllegalStateException e) {
runOnUiThread(new Runnable() {
@Override
public void run() {
seekBar.setProgress(0);
textView.setText("");
playImg.setVisibility(View.VISIBLE);
pauseImg.setVisibility(View.INVISIBLE);
player.releaseAudio();
}
});
e.printStackTrace();
break;
}
runOnUiThread(new Runnable() {
@Override
public void run() {
seekBar.setProgress(position * 100 / mediaDuration);
if (position >= mediaDuration) {
player.pauseAudio();
}
}
});
Thread.sleep(100);
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
만든 MediaThread 객체 및 Player 객체를 코드에 적용시킨 결과는 아래와 같다. thread를 적용할 때는 사용한 함수별로 따로 설명을 적지는 않았으니, 스스로 코드를 읽어보며 어느 버튼을 눌렀을 때 thread가 실행되고 interrupt 되는지 판단해보길 바란다.
public class MainActivity extends AppCompatActivity {
RecyclerView recyclerView;
RecordAdapter adapter = new RecordAdapter();
Button recordBtn, stopBtn, resumeBtn, pauseBtn;
ImageView playImg, pauseImg, forwardImg, backwardImg;
TextView textView;
SeekBar seekBar;
Recorder recorder;
Player player;
String filename;
SimpleDateFormat sDate = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.KOREA);
SimpleDateFormat sTime = new SimpleDateFormat("mm:ss", Locale.KOREA);
Thread thread;
int position = 0;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
if (ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED ||
ContextCompat.checkSelfPermission(this, Manifest.permission.WRITE_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED ||
ContextCompat.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) {
ActivityCompat.requestPermissions(this, new String[]{Manifest.permission.RECORD_AUDIO, Manifest.permission.WRITE_EXTERNAL_STORAGE, Manifest.permission.READ_EXTERNAL_STORAGE}, 0);
}
recyclerView = findViewById(R.id.recyclerView);
recordBtn = findViewById(R.id.recordbtn);
stopBtn = findViewById(R.id.stopbtn);
resumeBtn = findViewById(R.id.resumebtn);
pauseBtn = findViewById(R.id.pausebtn);
playImg = findViewById(R.id.play);
pauseImg = findViewById(R.id.pause);
forwardImg = findViewById(R.id.forward);
backwardImg = findViewById(R.id.backward);
seekBar = findViewById(R.id.seekBar);
textView = findViewById(R.id.textView);
textView.setText("");
LinearLayoutManager layoutManager = new LinearLayoutManager(this, LinearLayoutManager.VERTICAL, false);
recyclerView.setLayoutManager(layoutManager);
recyclerView.setAdapter(adapter);
File dir = new File(getExternalCacheDir().getAbsolutePath());
File[] files = dir.listFiles();
for (File file : files) {
parseNameAndAddItem(file.getAbsolutePath());
}
seekBar.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() {
@Override
public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) {
if (fromUser) {
player.updateSeekBar(progress);
}
}
@Override
public void onStartTrackingTouch(SeekBar seekBar) { }
@Override
public void onStopTrackingTouch(SeekBar seekBar) { }
});
adapter.setOnItemClickListener(new RecordAdapter.OnItemClickListener() {
@Override
public void onItemClicked(RecordAdapter.RecordItem item) {
if (thread != null) {
thread.interrupt();
}
filename = item.filename;
playImg.setVisibility(View.VISIBLE);
pauseImg.setVisibility(View.GONE);
textView.setText(item.name);
player = new Player(filename);
thread = new MediaThread();
thread.start();
}
});
recordBtn.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
if (thread != null) {
thread.interrupt();
}
filename = getExternalCacheDir().getAbsolutePath() + "/" + sDate.format(new Date()) + ".mp4";
recorder = new Recorder(filename);
recorder.startRecording();
recordBtn.setVisibility(View.GONE);
resumeBtn.setVisibility(View.GONE);
stopBtn.setVisibility(View.VISIBLE);
pauseBtn.setVisibility(View.VISIBLE);
}
});
stopBtn.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
recorder.stopRecording();
recordBtn.setVisibility(View.VISIBLE);
stopBtn.setVisibility(View.GONE);
resumeBtn.setVisibility(View.GONE);
pauseBtn.setVisibility(View.GONE);
Intent intent = new Intent(MainActivity.this, PopupActivity.class);
mStartForResult.launch(intent);
}
});
pauseBtn.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
recorder.pauseRecording();
pauseBtn.setVisibility(View.GONE);
resumeBtn.setVisibility(View.VISIBLE);
}
});
resumeBtn.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
recorder.resumeRecording();
resumeBtn.setVisibility(View.GONE);
pauseBtn.setVisibility(View.VISIBLE);
}
});
playImg.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
if (player.playAudio()) {
playImg.setVisibility(View.INVISIBLE);
pauseImg.setVisibility(View.VISIBLE);
}
}
});
pauseImg.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
if (player.pauseAudio()) {
playImg.setVisibility(View.VISIBLE);
pauseImg.setVisibility(View.INVISIBLE);
}
}
});
forwardImg.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
player.forwardAudio(5000);
}
});
backwardImg.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
player.backwardAudio(5000);
}
});
}
ActivityResultLauncher<Intent> mStartForResult = registerForActivityResult(
new ActivityResultContracts.StartActivityForResult(),
result -> {
if (result.getResultCode() == 0) {
Intent data = result.getData();
if (data == null) {
return;
}
String title = data.getStringExtra("title");
File file = new File(filename);
String newFilename = getExternalCacheDir().getAbsolutePath() + "/" + sDate.format(new Date()) + "(" + title + ").mp4";
file.renameTo(new File(newFilename));
parseNameAndAddItem(newFilename);
}
});
private void parseNameAndAddItem(String filename) {
if (!filename.contains(".mp4") || !filename.contains("(") || !filename.contains(")")) {
return;
}
String[] split = filename.split("/");
String name = split[split.length - 1];
String[] split2 = name.split("\\(");
String datetime = split2[0];
String[] split3 = split2[1].split("\\)");
String title = split3[0];
String[] split4 = datetime.split(" ");
String date = split4[0];
String length = sTime.format(new Date(getMP3FileLength(new File(filename))));
adapter.addItem(new RecordAdapter.RecordItem(title, length, date, filename));
adapter.notifyDataSetChanged();
}
public int getMP3FileLength(File file) {
int duration = 0;
try {
MediaPlayer tmpmediaPlayer = new MediaPlayer();
FileInputStream stream = new FileInputStream(file.getAbsolutePath());
tmpmediaPlayer.setDataSource(stream.getFD());
stream.close();
tmpmediaPlayer.prepare();
duration = tmpmediaPlayer.getDuration();
tmpmediaPlayer.release();
} catch (IOException e) {
e.printStackTrace();
}
return duration;
}
class MediaThread extends Thread {
@Override
public void run() {
int mediaDuration = player.getLength();
try {
while (!Thread.currentThread().isInterrupted()) {
try {
position = player.getPosition();
} catch (IllegalStateException e) {
runOnUiThread(new Runnable() {
@Override
public void run() {
seekBar.setProgress(0);
textView.setText("");
playImg.setVisibility(View.VISIBLE);
pauseImg.setVisibility(View.INVISIBLE);
player.releaseAudio();
}
});
e.printStackTrace();
break;
}
runOnUiThread(new Runnable() {
@Override
public void run() {
seekBar.setProgress(position * 100 / mediaDuration);
if (position >= mediaDuration) {
player.pauseAudio();
}
}
});
Thread.sleep(100);
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
아래 사진과 같이 정상적으로 녹음되고, 재생되는 것을 확인할 수 있다.
