Tistory View

실패한 이야기이므로 그냥 소스를 복사해봤자 도움이 안됩니다. 완성본을 올릴테니 그 것을 복사하세요.

일단 고려해야 될 사항

안드로이드앱에서 알림음(ringtone)을 재생할 일이 생겼다. 기기에는 기본적으로 제공하는 알림음이 있으니, 선택창을 띄우고 선택된 알림음을 받아와서 재생하는 것이 목적이었다. 별로 어려운 것도 아니고, 기본제공하는 UI로 만들기로 했다.

알림음들은 기기에 기본적으로 들어 있기 때문에, 사용자가 일부러 지우지 않는 이상 계속 존재하게 된다. 대부분의 사용자는 기본알림음을 변경하거나 삭제하지 않고 사용한다. 즉, 기본 알림음들은 굳이 존재하는 지를 체크할 필요가 없어서, 그냥, 있다는 전제하에 재생해 버리면 된다.

사용자가 따로 추가한 알림음은 이 것또한 알림음으로 등록이 된 것이라, 알림음 선택창에 전혀 문제없이 나타나며, 재생에도 문제가 없다. 단지 추가한 알림음을 다시 삭제한 경우 이 사용자는 중급사용자(?)로 보면 되기 때문에, 알림이 울리지 않거나 앱에 크래쉬되는 등의 문제상황을 스스로 대처 할 수 있다고 판단해도 될 둣하다. 하지만 이 경우도 대처하도록 하여, 알림음이 없으면 그냥 기본알림음을 사용하는 간단한 코드를 추가하면 된다.

앱 알림음은 공개된 것이라, 설치된 아무 앱에서나 재생이 가능하기 때문에, 재생을 할 때, 크게 오류처리를 할 필요가 없어 보인다.

 

프로그래밍 시작

만들 Activity의 레이아웃은 다름과 같다. activity_main.xml

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout
    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">


    <TextView
        android:id="@+id/ringtone_uri"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Choose ringtone"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintTop_toTopOf="parent"/>


    <RelativeLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"

        android:layout_alignParentBottom="true">

        <android.support.v7.widget.AppCompatButton
            android:id="@+id/show_ringtone_chooser"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="알림선택창띄우기"


            android:layout_alignParentLeft="true"
            android:layout_alignParentStart="true"
            />

        <android.support.v7.widget.AppCompatButton
            android:id="@+id/stop_play_ringtone"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="조용히해~"

            android:layout_alignParentEnd="true"
            android:layout_alignParentRight="true"

            />
    </RelativeLayout>

</RelativeLayout>

현재 선택된 링톤의 Uri를 표시하는 TextView, 알림 선택창을 띄우는 버튼, 알림이 울리고 있는 경우 멈추는 버튼, 총 3개로 구성되어 있다.

 

기기에서 지원하는 기본 [알림음 선택창]에서 어떤 알림음을 선택했는 지 받아 오려면 Activity의 onActivityResult함수를 이용해야 했다(다른 방법이 있는 지는..). 따라서, onActivityResult 사용을 위한 추가적인 내용을 포함하여 Acitivity는 다음의 구조를 갖게 된다.


Skeleton of MainActivity.java

public class MainActivity extends AppCompatActivity
	implements View.OnClickListener
{
	private final static int REQUESTCODE_RINGTONE_PICKER = 1000;
	
    protected void onCreate(Bundle savedInstanceState) {...}
    
    //-- 링톤을 재생하는 함수
    private void startRingtone( Uri uriRingtone ) { ... }

	//-- 재생중인 링톤을 중지하는 함수
    private void releaseRingtone() { ... }

	//-- 기본 알림창을 띄우기 위한 Intent생성
    // 기본 알림선택창을 띄우고 결과를 받아와야 하기 때문에 startActivityForResult를 써야한다.
    private void showRingtonChooser() {
        this.startActivityForResult( intent, REQUESTCODE_RINGTONE_PICKER );
    }
	
    //-- 알림선택창에서 넘어온 데이터를 처리하는 코드
    @Override
    protected void onActivityResult(int requestCode, int resultCode, Intent data) {
        super.onActivityResult(requestCode, resultCode, data);
        if( requestCode == REQUESTCODE_RINGTONE_PICKER )
        {
            if (resultCode == RESULT_OK)
            {
            	//-- 선택된 링톤을 재생하도록 한다.
                Uri ring = data.getParcelableExtra(RingtoneManager.EXTRA_RINGTONE_PICKED_URI);
                this.startRingtone( ring );
            }
        }
    }

	//-- 눌러진 버튼에 따라 처리하는 함수
    @Override
    public void onClick(View v) {...}
}



알림창 생성을 생생하는 코드를 작성해 본다.

	//-- 기본 알림창을 띄우기 위한 Intent생성
    //-- 기본 알림선택창을 띄우고 결과를 받아와야 하기 때문에 startActivityForResult를 써야한다.
    private void showRingtonChooser() {

        Intent intent = new Intent(RingtoneManager.ACTION_RINGTONE_PICKER);

        intent.putExtra(RingtoneManager.EXTRA_RINGTONE_TITLE, "Choose Ringtone!" );
        intent.putExtra(RingtoneManager.EXTRA_RINGTONE_SHOW_SILENT,  false);
        intent.putExtra(RingtoneManager.EXTRA_RINGTONE_SHOW_DEFAULT, true);
        
        intent.putExtra(RingtoneManager.EXTRA_RINGTONE_TYPE, RingtoneManager.TYPE_ALL);

        //-- 알림 선택창이 떴을 때, 기본값으로 선택되어질 ringtone설정
        if( m_strRingToneUri != null && m_strRingToneUri.isEmpty() )
        {
            intent.putExtra(RingtoneManager.EXTRA_RINGTONE_EXISTING_URI, Uri.parse( m_strRingToneUri ));
        }
        
        this.startActivityForResult( intent, REQUESTCODE_RINGTONE_PICKER );
    }

 

 

onActivityResult함수에서 넘어온 값이 올바른지 체크한다. 이 글의 예제코드에서는 사용되지 않지만, 링톤선택창에 묵음(Silent)을 넣을 수 있는 데, 묵음을 선택한 경우 null이 넘어 온다. 따라서 null체크는 사실상 이 코드에서는 필요가 없을 듯 하다.

//-- 알림선택창에서 넘어온 데이터를 처리하는 코드
    @Override
    protected void onActivityResult(int requestCode, int resultCode, Intent data) {
        super.onActivityResult(requestCode, resultCode, data);
        if( requestCode == REQUESTCODE_RINGTONE_PICKER )
        {
            if (resultCode == RESULT_OK)
            {
                // -- 알림음 재생하는 코드 -- //
                Uri ring = data.getParcelableExtra(RingtoneManager.EXTRA_RINGTONE_PICKED_URI);
                if (ring != null) {
                    m_tvRingtoneUri.setText( ring.toString() );
                    this.startRingtone( ring );
                } else {
                    m_tvRingtoneUri.setText( "Choose ringtone" );
                }
            }
        }
    }

 

전체 MainAcitivty.java

package a.b.c.d.playringtone;

import android.content.Intent;
import android.media.AudioManager;
import android.media.MediaPlayer;
import android.media.RingtoneManager;
import android.net.Uri;
import android.support.v7.app.AppCompatActivity;
import android.os.Bundle;
import android.util.Log;
import android.view.View;
import android.widget.TextView;
import android.widget.Toast;

public class MainActivity extends AppCompatActivity
implements View.OnClickListener
{
    private final static String TAG = "MainActivity";

    private final static int REQUESTCODE_RINGTONE_PICKER = 1000;


    private MediaPlayer mMediaPlayer;


    // View cache
    private TextView m_tvRingtoneUri;
    private String   m_strRingToneUri;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);


        m_tvRingtoneUri = this.findViewById( R.id.ringtone_uri );

        this.findViewById( R.id.show_ringtone_chooser ).setOnClickListener( this );
        this.findViewById( R.id.stop_play_ringtone    ).setOnClickListener( this );


    }

    //-- 링톤을 재생하는 함수
    private void startRingtone( Uri uriRingtone )
    {
        this.releaseRingtone();

        try {

            mMediaPlayer = MediaPlayer.create(getApplicationContext(), uriRingtone );

            if( mMediaPlayer == null ) {
                throw new Exception( "Can't create player" );
            }

            // STREAM_VOICE_CALL, STREAM_SYSTEM, STREAM_RING, STREAM_MUSIC, STREAM_ALARM
            // STREAM_NOTIFICATION, STREAM_DTMF
            //mMediaPlayer.setAudioStreamType( AudioManager.STREAM_ALARM );
            mMediaPlayer.setAudioStreamType( AudioManager.STREAM_MUSIC );
            //mMediaPlayer.setAudioAttributes();
            mMediaPlayer.start();

        }
        catch( Exception e ) {
            Toast.makeText( this, e.getMessage(), Toast.LENGTH_SHORT ).show();
            Log.e(TAG, e.getMessage() );
            e.printStackTrace();
        }
    }

    //-- 재생중인 링톤을 중지하는 함수
    private void releaseRingtone()
    {
        if( mMediaPlayer != null )
        {
            if( mMediaPlayer.isPlaying() ) {
                mMediaPlayer.stop();
            }
            mMediaPlayer.release();
            mMediaPlayer = null;
        }
    }

    //-- 기본 알림창을 띄우기 위한 Intent생성
    //-- 기본 알림선택창을 띄우고 결과를 받아와야 하기 때문에 startActivityForResult를 써야한다.
    private void showRingtonChooser() {

        Intent intent = new Intent(RingtoneManager.ACTION_RINGTONE_PICKER);

        intent.putExtra(RingtoneManager.EXTRA_RINGTONE_TITLE, "Choose Ringtone!" );
        intent.putExtra(RingtoneManager.EXTRA_RINGTONE_SHOW_SILENT,  false);
        intent.putExtra(RingtoneManager.EXTRA_RINGTONE_SHOW_DEFAULT, true);

        intent.putExtra(RingtoneManager.EXTRA_RINGTONE_TYPE, RingtoneManager.TYPE_ALL);

        //-- 알림 선택창이 떴을 때, 기본값으로 선택되어질 ringtone설정
        if( m_strRingToneUri != null && m_strRingToneUri.isEmpty() )
        {
            intent.putExtra(RingtoneManager.EXTRA_RINGTONE_EXISTING_URI, Uri.parse( m_strRingToneUri ));
        }

        this.startActivityForResult( intent, REQUESTCODE_RINGTONE_PICKER );
    }

    //-- 알림선택창에서 넘어온 데이터를 처리하는 코드
    @Override
    protected void onActivityResult(int requestCode, int resultCode, Intent data) {
        super.onActivityResult(requestCode, resultCode, data);
        if( requestCode == REQUESTCODE_RINGTONE_PICKER )
        {
            if (resultCode == RESULT_OK)
            {
                // -- 알림음 재생하는 코드 -- //
                Uri ring = data.getParcelableExtra(RingtoneManager.EXTRA_RINGTONE_PICKED_URI);


                if (ring != null) {

                    m_strRingToneUri = ring.toString();
                    m_tvRingtoneUri.setText( ring.toString() );
                    
                    this.startRingtone( ring );

                } else {
                    m_strRingToneUri = null;
                    m_tvRingtoneUri.setText( "Choose ringtone" );
                }
            }
        }
    }


    //-- 눌러진 버튼에 따라 처리하는 함수
    @Override
    public void onClick(View v) {

        switch( v.getId() ) {
            case R.id.show_ringtone_chooser:
                showRingtonChooser();
                break;
            case R.id.stop_play_ringtone:
                this.releaseRingtone();
                break;
        }
    }
}

 

 

링톤 선택 창이 뜬 모습( LG G2 )

선택 후 OK를 누르면 선택된 링톤이 플레이가 된다.

이 예제는 에뮬레이터들과 삼성 디바이스와 LG디바이스등에서는 전혀 문제가 되지 않았다. 삼성디바이스의 경우 S6-Edge(Nougat)까지 테스트를 하였는 데, 아주 잘 작동을 하였다.

 

무슨 문제가 있는가?

LG디바이스에서 링톤을 선택한 경우, 링톤의 Uri값을 들여다보면 [content://media/internal/audio/media/87]값이 들어있다. 이 값은 Content저장소에 들어 있는 값이고 이런 것들은 접근하는 데 문제가 없다. 하지만 문제는 샤오미폰[포코폰F1]에서 발생하였다. 사오미폰에서는 창이 다음과 같이 떠서 외부음악파일 또한 선택할 수가 있다.

이렇게 선택된 [오디오]파일의 Uri값을 보면 [file:///storage/emulated/0/~~~~~] 이렇게 되어 있다. 하..... 눈치 빠른 독자는 바로 이해하겠지만 이건 외부파일이라 접근을 위해서는 MarshMallow부터 적용되는 Dangerous Permission인 [READ_EXTERNAL_STORAGE]권한이 필요하다. 제작중이던 앱은 [READ_EXTERNAL_STORAGE] 권한없이 만들려고 했던터라(주1) 이런방식을 쓰는 중국회사들의 장치(주2)에서는 java.io.FileNotFoundException: /storage/emulated/0/1/~~~ (Permission denied) 이 발생하여 실패하게 된다(주3). 현시점에서 약10%에서 많게는 40%의 장치에서 오류가 날 확률이 있는 것이다.

필자는 맨 위의 "고려해야 될 상황"에서 이 부분을 고려하지 않았다. "링톤선택기니까~ 당연히 링톤에서만 선택되어야 하고 이 링톤은 접근이 늘 가능한 게 당연하지~" 이 부분이 문제가 되는 것이었다.(주4)

 

해결책을 모색해 보자

필자는 이제 고민에 빠지게 되었다. 사용자에게 권한을 얻어 온다고 해도 다음의 문제들이 또 발생한다.

파일이 삭제되는 경우를 대비하여 앱의 캐쉬디렉토리로 복사를 해두어야 한다. 이로인해 스토리지 공간을 잡아 먹게 되고, 여러번 선택하면 어떤걸 지워야 할지 계산해야 되고, 같은 파일이 여러개 생성되고, 이 선택하는 부분에서 파일을 복사하느라 큰 파일일 경우(flac등) 시간도 많이 걸린다.

권한없이 처리하고 싶었는데, 권한을 획득해야 하는 것이 필자에게는 짜증이 폭발해 버릴 정도였다. 다른 부분의 코드들이 이리저리 권한처리없이 다 피해다니며 만들어왔는데...

이 부분은 Provider를 이용해 선택창을 담당하는 Activity가 작성한 앱쪽으로 파일을 읽을 수 있게 처리되었어야 하는 문제다. 하지만 필자는 이 방법을 지원하지 않는다는 것을 알았다.(혹시나 이게 가능하면 방법 좀 댓글로 부탁드립니다)

해결은 생각보다 쉬웠다

RingtoneManager에서 getRingtone함수를 이용하여 Ringtone instance를 만들어 재생하면 권한 획득없이 플레이가 가능하다.

https://developer.android.com/reference/android/media/RingtoneManager

static Ringtone getRingtone(Context context, Uri ringtoneUri)

이 방법이 (거의)모든장치에서 동작하는 지는 확인할 수 없지만, 잘 될 거라 생각하며 코드를 바꾸기로 했다. 이 방법이 되게 우낀게 테스트를 해보진 않았지만, 장치내의 음악파일을 읽기권한도 없이 다 재생이 가능하다는 말이 되기도 한다. 이 점은 확실히 잘못된 부분이다. 링톤으로 등록된 것만 처리되는 것이 올바른 것이다.

 

다음에는 위의 Ringtone instance를 이용한 코드로 변경하여 포스트할 것이다.

 


(주1) : 권한 받아오는 코드 적용하는 것도 귀찮고, 제작중이던 앱에는 이 [READ_EXTERNAL_STORAGE]권한 없이 만들어야 겠다는 생각이 깊이 박혀있던 상황이었다.

(주2) : 중국브랜드의 장치들이 얼마나 이 방식을 쓰는 지는 브랜드마다 안드로이드 버전마다 다 사서 테스트를 할 수는 없는 노릇이고, 문제가 생기는 것을 PLAY CONSOLE에서 발생할 때마다 원인을 대처하는 것도 사실상 불가능하다. 지금 테스트폰만 10개고 에뮬레이터도 10개인데 또 더 테스트하는 것도..ㅠㅠ

(주3):  필자가 제작중이던 앱에서는 링톤의 길이를 알아오는 코드가 있었는 데, 권한에 신경을 안쓰고, 성공의 케이스만 체크하는 바람에 null point exception이 발생하였다. 당연히 catch하는 코드가 없었으며, 이로인해, Activity가 그냥 크래쉬가 되어 버렸다. 실제 이 크래쉬를 당하는 사람의 입장에서는 기분이 매우 나쁜 경우가 많다. 바로 앱 지워버리는 상황이 발생할 수 있다.

(주4): 솔직히 지금도 이 생각은 잘 못되었다고 생각하지 않는다. 구글에서 이런 문제가 발생하지 않도록 가이드라인을 제조사들에게 넘기고 관리감독을 해야 한다고 생각한다.

 

 

Replies
  • Profile photo KM29A1 어우 고생하셨습니다. 저도 덕분에 많은 도움이 되었습니다. 2020.05.19 15:30
Reply Write