DEV Community

Ochornma Promise
Ochornma Promise

Posted on

Developing android radio app

I, decided to pen down my thoughts and experience from developing the DCLM Radio app.

To develop a radio app, it is important to seek permission for foreground service, wake lock and internet.

<uses-permission android:name="android.permission.WAKE_LOCK" />
    <uses-permission android:name="android.permission.INTERNET" />
    <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />

    <application
        android:allowBackup="true"
        android:icon="@mipmap/nlogo"
        android:label="@string/app_name"
        android:networkSecurityConfig="@xml/network_security_config"
        android:supportsRtl="true"
        android:usesCleartextTraffic="true"
        tools:targetApi="m">
        <uses-library
            android:name="org.apache.http.legacy"
            android:required="false" />
Enter fullscreen mode Exit fullscreen mode

From android documentation, the android:networkSecurityConfig

“lets apps customize their network security settings in a safe, declarative configuration file without modifying app code. These settings can be configured for specific domains and for a specific app.”
From the documentation, the followings are the key capabilities of this feature
Custom trust anchors:
Customize which Certificate Authorities (CA) are trusted for an app’s secure connections. For example, trusting particular self-signed certificates or restricting the set of public CAs that the app trusts.
Debug-only overrides: Safely debug secure connections in an app without added risk to the installed base.
Cleartext traffic opt-out: Protect apps from accidental usage of cleartext traffic.
Certificate pinning: Restrict an app’s secure connection to particular certificates.

From my experience, this is necessary if the domain throws http and you need your app to function on Android 8 and above. Below is a format for the network configuration xml file.

<network-security-config>
    <domain-config cleartextTrafficPermitted="true">
        <domain includeSubdomains="true">domain name without https://</domain>
    </domain-config>
</network-security-config>
Enter fullscreen mode Exit fullscreen mode

I optimized the app to function on standalone Wear OS. To do this, I added a new module from the file menu > New > New Module and then selected the WearOS Module. then updated the build.gradle of the wear mode with the following

implementation 'androidx.wear:wear:1.0.0'
implementation 'com.google.android.support:wearable:2.5.0'
compileOnly 'com.google.android.wearable:wearable:2.5.0'

Then the WearOS manifest file updated to allow the app run WearOS

<uses-permission android:name="android.permission.WAKE_LOCK" />
    <uses-feature android:name="android.hardware.type.watch" />

    <uses-permission android:name="android.permission.INTERNET" />
    <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />

    <application
        android:allowBackup="true"
        android:networkSecurityConfig="@xml/network_security_config"
        android:supportsRtl="true"
        android:usesCleartextTraffic="true"
        tools:targetApi="m">
        <uses-library
            android:name="com.google.android.wearable"
            android:required="false" />

        <uses-library
            android:name="org.apache.http.legacy"
            android:required="false" />

        <!--
               Set to true if your app is Standalone, that is, it does not require the handheld
               app to run.
        -->
        <meta-data
            android:name="com.google.android.wearable.standalone"
            android:value="true" />
Enter fullscreen mode Exit fullscreen mode

For the app to work both on square and round wearOS, you need this attribute boxedEdges on the wearOS layout file.


app:boxedEdges="all"
Enter fullscreen mode Exit fullscreen mode

I used the Exoplayer library as against the Android multimedia framework (MediaPlayer).
The Exoplayer supports DASH Streaming, HLS which are not supported by MediaPlayer.
Below is the layout for the app

<LinearLayout 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"
    android:background="@drawable/uiradio"
    android:orientation="vertical">
    <com.google.android.material.appbar.AppBarLayout
        android:elevation="0dp"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        tools:targetApi="lollipop">

        <androidx.appcompat.widget.Toolbar
            android:id="@+id/app_bar"
            style="@style/Widget.DCLM.Toolbar"
            android:layout_width="match_parent"
            android:layout_height="?attr/actionBarSize"
            app:navigationIcon="@drawable/nlogo11"
            app:title="@string/app_name" />
    </com.google.android.material.appbar.AppBarLayout>

    <ScrollView
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:elevation="8dp"
        tools:targetApi="lollipop">

    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content">

        <ImageView
            android:id="@+id/imageView"
            android:layout_width="120dp"
            android:layout_height="120dp"
            android:layout_marginStart="8dp"
            android:layout_marginTop="30dp"
            android:layout_marginEnd="8dp"
            android:src="@drawable/nlogo"
            android:contentDescription="@string/image_view_dclm_logo"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent" />


        <ImageButton
            android:id="@+id/play"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:background="@android:color/transparent"
            android:contentDescription="@string/play_button"
            android:src="@drawable/ic_play"
            app:layout_constraintBottom_toTopOf="@+id/up_next"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toBottomOf="@id/preacher" />


        <Button
            android:id="@+id/live"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginTop="4dp"
            android:background="@android:color/transparent"
            android:contentDescription="@string/stop_button"
            android:text="@string/live"
            android:textAllCaps="true"
            android:textAppearance="?attr/textAppearanceBody1"
            android:textColor="@color/colorAccent"
            android:textSize="20sp"
            android:textStyle="bold"
            android:visibility="invisible"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toBottomOf="@+id/stop" />

        <ImageButton
            android:id="@+id/stop"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:background="@android:color/transparent"
            android:contentDescription="@string/stop_button"
            android:src="@drawable/ic_pause"
            android:visibility="invisible"
            app:layout_constraintBottom_toTopOf="@+id/up_next"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toBottomOf="@id/preacher" />


        <TextView
            android:id="@+id/title"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginTop="12dp"
            android:fontFamily="sans-serif-condensed-medium"
            android:paddingLeft="8dp"
            android:paddingRight="8dp"
            android:textAlignment="center"
            android:textColor="#fff"
            android:textAppearance="@style/TextAppearance.DCLM.Title"
            android:textSize="15sp"
            android:textStyle="normal|bold"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toBottomOf="@+id/imageView" />


        <TextView
            android:id="@+id/preacher"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginTop="4dp"
            android:fontFamily="sans-serif-condensed-medium"
            android:textSize="20sp"
            android:textColor="#fff"
            android:paddingLeft="8dp"
            android:paddingRight="8dp"
            android:textStyle="normal"
            android:textAppearance="?attr/textAppearanceBody2"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toBottomOf="@id/title" />

        <TextView
            android:id="@+id/up_next"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginBottom="4dp"
            android:textColor="#fff"
            android:textSize="15sp"
            android:textAppearance="?attr/textAppearanceSubtitle1"
            app:layout_constraintBottom_toTopOf="@+id/tittle_next"
            app:layout_constraintTop_toBottomOf="@+id/live"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent" />

        <TextView
            android:id="@+id/tittle_next"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginBottom="16dp"
            android:textAlignment="center"
            android:textColor="#fff"
            android:textSize="15sp"
            android:textAppearance="?attr/textAppearanceSubtitle2"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent" />




    </androidx.constraintlayout.widget.ConstraintLayout>

    </ScrollView>

</LinearLayout>
Enter fullscreen mode Exit fullscreen mode

Since the app will be playing on background even when the activity lifecycle is in onStop state we will need the Android service.
We will be using the Foreground service and will be bounding it to the MainActivity.

public class RadioService extends Service {

    private final IBinder binder2 = new RadioLocalBinder();

    public SimpleExoPlayer player;
    private PlayerNotificationManager playerNotificationManager;
    private MediaSessionCompat mediaSession;
    private MediaSessionConnector mediaSessionConnector;
    private Context context;
    private boolean check;
    private  MediaSource mediaSource;


    @Override
    public void onCreate() {
        super.onCreate();
        context = this;

        AudioAttributes audioAttributes = new AudioAttributes.Builder()
                .setUsage(com.google.android.exoplayer2.C.USAGE_MEDIA)
                .setContentType(com.google.android.exoplayer2.C.CONTENT_TYPE_SPEECH)
                .build();

        player = ExoPlayerFactory.newSimpleInstance(this);
        player.setAudioAttributes(audioAttributes,true);

    }

    public void prepare(){
        String userAgent = Util.getUserAgent(context, "DCLM Radio");

        DefaultHttpDataSourceFactory httpDataSourceFactory = new DefaultHttpDataSourceFactory(
                userAgent,
                null /* listener */,
                30 * 1000,
                30*1000,
                true  //allowCrossProtocolRedirects
        );

        mediaSource = new ProgressiveMediaSource.Factory(httpDataSourceFactory)
                .createMediaSource(Uri.parse(getString(R.string.radio_link)));
        player.prepare(mediaSource);
        player.setPlayWhenReady(true);

    }


    @Nullable
    @Override
    public IBinder onBind(Intent intent) {
        return binder2;
    }

    @Override
    public int onStartCommand(Intent intent, int flags, int startId) {
         check = true;
            String userAgent = Util.getUserAgent(context, getString(R.string.app_name));

// Default parameters, except allowCrossProtocolRedirects is true

            DefaultHttpDataSourceFactory httpDataSourceFactory = new DefaultHttpDataSourceFactory(
                    userAgent,
                    null /* listener */,
                    30 * 1000, 
                    30*1000,
                    true  //allowCrossProtocolRedirects
            );


            mediaSource = new ProgressiveMediaSource.Factory(httpDataSourceFactory)
                    .createMediaSource(Uri.parse(getString(R.string.radio_link)));
            player.prepare(mediaSource);

            playerNotificationManager = PlayerNotificationManager.createWithNotificationChannel(context, "playback_channel",
                    R.string.app_name, R.string.app_describe, 1, new PlayerNotificationManager.MediaDescriptionAdapter() {
                        @Override
                        public String getCurrentContentTitle(Player player) {
                            return getString(R.string.app_name);
                        }

                        @Nullable
                        @Override
                        public PendingIntent createCurrentContentIntent(Player player) {
                            Intent intent = new Intent(context, MainActivity.class);
                          intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK)
                            return PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT);
                        }

                        @Nullable
                        @Override
                        public String getCurrentContentText(Player player) {
                            return getString(R.string.app_describe);
                        }

                        @Nullable
                        @Override
                        public Bitmap getCurrentLargeIcon(Player player, PlayerNotificationManager.BitmapCallback callback) {
                            return getBitmap(context, R.drawable.nlogo);
                        }
                    }, new PlayerNotificationManager.NotificationListener() {
                        @Override
                        public void onNotificationCancelled(int notificationId, boolean dismissedByUser) {
                          stopForeground(true);
                        }

                        @Override
                        public void onNotificationPosted(int notificationId, Notification notification, boolean ongoing) {
                            startForeground(notificationId, notification);
                        }
                    });


            playerNotificationManager.setSmallIcon(R.drawable.nlogo);
            playerNotificationManager.setUseStopAction(true);
            playerNotificationManager.setUseNavigationActions(false);
            playerNotificationManager.setUseStopAction(false);
            playerNotificationManager.setPlayer(player);
            mediaSession = new MediaSessionCompat(context, "dclm_radio");
            mediaSession.setActive(true);
            playerNotificationManager.setMediaSessionToken(mediaSession.getSessionToken());
            mediaSessionConnector = new MediaSessionConnector(mediaSession);
            mediaSessionConnector.setPlayer(player);
        MediaButtonReceiver.handleIntent(mediaSession, intent);
        return START_STICKY;
    }

    public static Bitmap getBitmap(Context context, @DrawableRes int bitmapResource) {
        return ((BitmapDrawable) context.getResources().getDrawable(bitmapResource)).getBitmap();
    }

    public class RadioLocalBinder extends Binder {
        DCLMRadioService getService2(){
            // Return this instance of LocalService so clients can call public methods
            return DCLMRadioService.this;
        }
    }

    public void pausePlayer(){
        if(player.isPlaying()) {
            player.setPlayWhenReady(false);
            player.getPlaybackState();
        }
    }
    public void startPlayer(){
        if(!player.isPlaying()) {
            player.setPlayWhenReady(true);
            player.getPlaybackState();
        }
    }

        @Override
    public void onDestroy() {
        if(check){
            mediaSession.release();
            mediaSessionConnector.setPlayer(null);
            playerNotificationManager.setPlayer(null);
            player.release();
        }
        player = null;
        super.onDestroy();
    }
}
Enter fullscreen mode Exit fullscreen mode

For the audio focus I choose C.CONTENT_TYPE_SPEECH since I want the radio to pause, when another audio is played since it is a podcast. There are other types you can choose such as C.CONTENT_TYPE_MOVIE, C.CONTENT_TYPE_MUSIC, C.CONTENT_TYPE_SONIFICATIONor C.CONTENT_TYPE_UNKNOWN.
I also increased the DefaultHttpDataSourceFactory connection and read timeout to 30 Seconds because the default time was giving socket timeout exception (Caused by: java.net.SocketTimeoutException: connect timed out)
For the onDestroy, I used the boolean check to detect if the mediaSession, mediaSessionConnector, playerNotificationManager and the player is not null.
If they are null, trying to release those resources will throw a nullpointer exception

public class MainActivity extends AppCompatActivity {

    public static final String PREFRENCES = "com.dclm.radio";

    public Boolean isOnline;
    private Intent intent;
    RadioService radioService;
    boolean mBound = false;
    private ImageButton buttonPlay, buttonPause;
    private Button buttonLive
    private SharedPreferences sharedPreferences;
    // Record whether audio is playing or not.
    private int audioIsPlaying;

    private ServiceConnection connection = new ServiceConnection() {

        @Override
        public void onServiceConnected(ComponentName className,
                                       IBinder service2) {
            // We've bound to LocalService, cast the IBinder and get LocalService instance
            RadioService.RadioLocalBinder binder2 = (RadioService.RadioLocalBinder) service2;
            radioService = binder2.getService2();
            mBound = true;
        }

        @Override
        public void onServiceDisconnected(ComponentName arg0) {
            mBound = false;
        }
    };
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        //registerUpdateOnNetwork();

        //stopping();
        audioIsPlaying = 0;
        sharedPreferences = getSharedPreferences(PREFRENCES, Context.MODE_PRIVATE);
        SharedPreferences.Editor editor = sharedPreferences.edit();
        editor.putInt("audioIsPlaying", audioIsPlaying);
        editor.apply();

// find the views of the listed i.e initialize the views
        buttonPlay = findViewById(R.id.play);
        buttonPause = findViewById(R.id.stop);
        buttonlive = findViewById(R.id.live);
        buttonPause.setVisibility(View.INVISIBLE);
        buttonlive.setVisibility(View.INVISIBLE);

        // seek the onclick listener for the button

        buttonPlay.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View view) {
                SharedPreferences prefs = getSharedPreferences(PREFRENCES, MODE_PRIVATE);
                int resume = prefs.getInt("audioIsPlaying", 0); //0 is the default value.
                if(resume != 10){
                    intent = new Intent(MainActivity.this, RadioService.class);
                    Util.startForegroundService(MainActivity.this, intent);
                }
                    int state = dclmRadioService.player.getPlaybackState()
                        buttonPlay.setVisibility(View.INVISIBLE);
                        buttonPause.setVisibility(View.VISIBLE);
                        buttonlive.setVisibility(View.VISIBLE);
                            if (state == Player.STATE_READY) {
                                radioService.startPlayer();
                                audioIsPlaying = 10;
                                SharedPreferences.Editor editor = sharedPreferences.edit();
                                editor.putInt("audioIsPlaying", audioIsPlaying);
                                editor.apply();
                            } else if (state == Player.STATE_IDLE) {
                                intent = new Intent(MainActivity.this, RadioService.class);
                                Util.startForegroundService(MainActivity.this, intent);
                                //dclmRadioService.prepare();
                                Handler mHandler = new Handler(getMainLooper());
                                mHandler.post(new Runnable() {
                                    @Override
                                    public void run() {
                                        dclmRadioService.startPlayer();
                                    }
                                });

                                audioIsPlaying = 10;
                                SharedPreferences.Editor editor = sharedPreferences.edit();
                                editor.putInt("audioIsPlaying", audioIsPlaying);
                                editor.apply();
                            } else if (state == Player.STATE_ENDED) {
                                intent = new Intent(MainActivity.this, RadioService.class);
                                Util.startForegroundService(MainActivity.this, intent);
                                radioService.startPlayer();
                            }
                }

        });


        buttonPause.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View view) {

                buttonPause.setVisibility(View.INVISIBLE);
                buttonPlay.setVisibility(View.VISIBLE);
                Handler mHandler = new Handler(getMainLooper());
                mHandler.post(new Runnable() {
                    @Override
                    public void run() {
                        dclmRadioService.pausePlayer();
                    }
                });
                int state2 = radioService.player.getPlaybackState();
            }
        });

        buttonlive.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                dclmRadioService.prepare();
                buttonPlay.setVisibility(View.INVISIBLE);
                buttonPause.setVisibility(View.VISIBLE);
            }
        });

}

    @Override
    protected void onResume() {
        super.onResume();
        SharedPreferences prefs = getSharedPreferences(PREFRENCES, MODE_PRIVATE);
        int resume = prefs.getInt("audioIsPlaying", 0); //0 is the default value.
        if(resume == 10) {

            boolean remain = radioService.isPlaying();
           Log.i("Main", String.valueOf(remain));
           if (!remain){
                buttonPause.setVisibility(View.VISIBLE);
                buttonPlay.setVisibility(View.INVISIBLE);
            } else {
               buttonPause.setVisibility(View.INVISIBLE);
               buttonPlay.setVisibility(View.VISIBLE);
           }
        }
    }

    @Override
    protected void onStart() {
        super.onStart();
        // Bind to DCLMService
        Intent intent = new Intent(this, RadioService.class);
        bindService(intent, connection, BIND_AUTO_CREATE);
    }



    @Override
    protected void onStop() {
        super.onStop();
        if(audioIsPlaying != 5) {
            unbindService(connection);
            mBound = false;
        }
    }

    @Override
    public void onBackPressed() {
            Intent setIntent = new Intent(Intent.ACTION_MAIN);
            setIntent.addCategory(Intent.CATEGORY_HOME);
            setIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
            startActivity(setIntent);
    }

    @Override
    protected void onDestroy() {
        intent = new Intent(MainActivity.this, RadioService.class);
        stopService(intent);
        super.onDestroy();
    }
}
Enter fullscreen mode Exit fullscreen mode

Above is the MainActivity of the app. It is bounded to the service at the onStart lifecycle of the app and unbounded in the onStop.

It is important to check if the WearOS has an inbuilt speaker device before starting the background service as this is one of the prerequisite for developing a media player for WearOS as stated on Android guide for WearOS

PackageManager packageManager = getPackageManager();
            // The results from AudioManager.getDevices can't be trusted unless the device
            // advertises FEATURE_AUDIO_OUTPUT.
            if (!packageManager.hasSystemFeature(PackageManager.FEATURE_AUDIO_OUTPUT)) {
                return false;
            }
            AudioManager audioManager = (AudioManager) getSystemService(Context.AUDIO_SERVICE);
            AudioDeviceInfo[] devices = audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS);
            for (AudioDeviceInfo device : devices) {
                if (device.getType() == AudioDeviceInfo.TYPE_BUILTIN_SPEAKER) {
                    return true;
                }
            }
Enter fullscreen mode Exit fullscreen mode

Whenever the app is destroyed, the background service is stopped/destroyed (stopService()).

The exoplayer/service is started using the Util.startForegroundService(MainActivity.this, intent);

Note: if the service need to be stated before it can persist even when the app is in onStop state and that’s the reason the player is started in the onStartCommand of the RadioService even the Notification, media session and media session connector
The buttonLive is used to recreate the player, if the app has been paused for a long time. It is intended to imitate the YouTube live button.

Top comments (1)

Collapse
 
Sloan, the sloth mascot
Comment deleted