Movatterモバイル変換


[0]ホーム

URL:


Skip to content
DEV Community
Log in Create account

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-permissionandroid:name="android.permission.WAKE_LOCK"/><uses-permissionandroid:name="android.permission.INTERNET"/><uses-permissionandroid:name="android.permission.FOREGROUND_SERVICE"/><applicationandroid: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-libraryandroid:name="org.apache.http.legacy"android:required="false"/>
Enter fullscreen modeExit 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-configcleartextTrafficPermitted="true"><domainincludeSubdomains="true">domain name without https://</domain></domain-config></network-security-config>
Enter fullscreen modeExit 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-permissionandroid:name="android.permission.WAKE_LOCK"/><uses-featureandroid:name="android.hardware.type.watch"/><uses-permissionandroid:name="android.permission.INTERNET"/><uses-permissionandroid:name="android.permission.FOREGROUND_SERVICE"/><applicationandroid:allowBackup="true"android:networkSecurityConfig="@xml/network_security_config"android:supportsRtl="true"android:usesCleartextTraffic="true"tools:targetApi="m"><uses-libraryandroid:name="com.google.android.wearable"android:required="false"/><uses-libraryandroid: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-dataandroid:name="com.google.android.wearable.standalone"android:value="true"/>
Enter fullscreen modeExit 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 modeExit 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

<LinearLayoutxmlns: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.AppBarLayoutandroid:elevation="0dp"android:layout_width="match_parent"android:layout_height="wrap_content"tools:targetApi="lollipop"><androidx.appcompat.widget.Toolbarandroid: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><ScrollViewandroid:layout_width="match_parent"android:layout_height="wrap_content"android:elevation="8dp"tools:targetApi="lollipop"><androidx.constraintlayout.widget.ConstraintLayoutandroid:layout_width="match_parent"android:layout_height="wrap_content"><ImageViewandroid: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"/><ImageButtonandroid: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"/><Buttonandroid: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"/><ImageButtonandroid: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"/><TextViewandroid: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"/><TextViewandroid: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"/><TextViewandroid: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"/><TextViewandroid: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 modeExit fullscreen mode

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

publicclassRadioServiceextendsService{privatefinalIBinderbinder2=newRadioLocalBinder();publicSimpleExoPlayerplayer;privatePlayerNotificationManagerplayerNotificationManager;privateMediaSessionCompatmediaSession;privateMediaSessionConnectormediaSessionConnector;privateContextcontext;privatebooleancheck;privateMediaSourcemediaSource;@OverridepublicvoidonCreate(){super.onCreate();context=this;AudioAttributesaudioAttributes=newAudioAttributes.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);}publicvoidprepare(){StringuserAgent=Util.getUserAgent(context,"DCLM Radio");DefaultHttpDataSourceFactoryhttpDataSourceFactory=newDefaultHttpDataSourceFactory(userAgent,null/* listener */,30*1000,30*1000,true//allowCrossProtocolRedirects);mediaSource=newProgressiveMediaSource.Factory(httpDataSourceFactory).createMediaSource(Uri.parse(getString(R.string.radio_link)));player.prepare(mediaSource);player.setPlayWhenReady(true);}@Nullable@OverridepublicIBinderonBind(Intentintent){returnbinder2;}@OverridepublicintonStartCommand(Intentintent,intflags,intstartId){check=true;StringuserAgent=Util.getUserAgent(context,getString(R.string.app_name));// Default parameters, except allowCrossProtocolRedirects is trueDefaultHttpDataSourceFactoryhttpDataSourceFactory=newDefaultHttpDataSourceFactory(userAgent,null/* listener */,30*1000,30*1000,true//allowCrossProtocolRedirects);mediaSource=newProgressiveMediaSource.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,newPlayerNotificationManager.MediaDescriptionAdapter(){@OverridepublicStringgetCurrentContentTitle(Playerplayer){returngetString(R.string.app_name);}@Nullable@OverridepublicPendingIntentcreateCurrentContentIntent(Playerplayer){Intentintent=newIntent(context,MainActivity.class);intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK|Intent.FLAG_ACTIVITY_CLEAR_TASK)returnPendingIntent.getActivity(context,0,intent,PendingIntent.FLAG_UPDATE_CURRENT);}@Nullable@OverridepublicStringgetCurrentContentText(Playerplayer){returngetString(R.string.app_describe);}@Nullable@OverridepublicBitmapgetCurrentLargeIcon(Playerplayer,PlayerNotificationManager.BitmapCallbackcallback){returngetBitmap(context,R.drawable.nlogo);}},newPlayerNotificationManager.NotificationListener(){@OverridepublicvoidonNotificationCancelled(intnotificationId,booleandismissedByUser){stopForeground(true);}@OverridepublicvoidonNotificationPosted(intnotificationId,Notificationnotification,booleanongoing){startForeground(notificationId,notification);}});playerNotificationManager.setSmallIcon(R.drawable.nlogo);playerNotificationManager.setUseStopAction(true);playerNotificationManager.setUseNavigationActions(false);playerNotificationManager.setUseStopAction(false);playerNotificationManager.setPlayer(player);mediaSession=newMediaSessionCompat(context,"dclm_radio");mediaSession.setActive(true);playerNotificationManager.setMediaSessionToken(mediaSession.getSessionToken());mediaSessionConnector=newMediaSessionConnector(mediaSession);mediaSessionConnector.setPlayer(player);MediaButtonReceiver.handleIntent(mediaSession,intent);returnSTART_STICKY;}publicstaticBitmapgetBitmap(Contextcontext,@DrawableResintbitmapResource){return((BitmapDrawable)context.getResources().getDrawable(bitmapResource)).getBitmap();}publicclassRadioLocalBinderextendsBinder{DCLMRadioServicegetService2(){// Return this instance of LocalService so clients can call public methodsreturnDCLMRadioService.this;}}publicvoidpausePlayer(){if(player.isPlaying()){player.setPlayWhenReady(false);player.getPlaybackState();}}publicvoidstartPlayer(){if(!player.isPlaying()){player.setPlayWhenReady(true);player.getPlaybackState();}}@OverridepublicvoidonDestroy(){if(check){mediaSession.release();mediaSessionConnector.setPlayer(null);playerNotificationManager.setPlayer(null);player.release();}player=null;super.onDestroy();}}
Enter fullscreen modeExit 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

publicclassMainActivityextendsAppCompatActivity{publicstaticfinalStringPREFRENCES="com.dclm.radio";publicBooleanisOnline;privateIntentintent;RadioServiceradioService;booleanmBound=false;privateImageButtonbuttonPlay,buttonPause;privateButtonbuttonLiveprivateSharedPreferencessharedPreferences;// Record whether audio is playing or not.privateintaudioIsPlaying;privateServiceConnectionconnection=newServiceConnection(){@OverridepublicvoidonServiceConnected(ComponentNameclassName,IBinderservice2){// We've bound to LocalService, cast the IBinder and get LocalService instanceRadioService.RadioLocalBinderbinder2=(RadioService.RadioLocalBinder)service2;radioService=binder2.getService2();mBound=true;}@OverridepublicvoidonServiceDisconnected(ComponentNamearg0){mBound=false;}};@OverrideprotectedvoidonCreate(BundlesavedInstanceState){super.onCreate(savedInstanceState);setContentView(R.layout.activity_main);//registerUpdateOnNetwork();//stopping();audioIsPlaying=0;sharedPreferences=getSharedPreferences(PREFRENCES,Context.MODE_PRIVATE);SharedPreferences.Editoreditor=sharedPreferences.edit();editor.putInt("audioIsPlaying",audioIsPlaying);editor.apply();// find the views of the listed i.e initialize the viewsbuttonPlay=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 buttonbuttonPlay.setOnClickListener(newView.OnClickListener(){@OverridepublicvoidonClick(Viewview){SharedPreferencesprefs=getSharedPreferences(PREFRENCES,MODE_PRIVATE);intresume=prefs.getInt("audioIsPlaying",0);//0 is the default value.if(resume!=10){intent=newIntent(MainActivity.this,RadioService.class);Util.startForegroundService(MainActivity.this,intent);}intstate=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.Editoreditor=sharedPreferences.edit();editor.putInt("audioIsPlaying",audioIsPlaying);editor.apply();}elseif(state==Player.STATE_IDLE){intent=newIntent(MainActivity.this,RadioService.class);Util.startForegroundService(MainActivity.this,intent);//dclmRadioService.prepare();HandlermHandler=newHandler(getMainLooper());mHandler.post(newRunnable(){@Overridepublicvoidrun(){dclmRadioService.startPlayer();}});audioIsPlaying=10;SharedPreferences.Editoreditor=sharedPreferences.edit();editor.putInt("audioIsPlaying",audioIsPlaying);editor.apply();}elseif(state==Player.STATE_ENDED){intent=newIntent(MainActivity.this,RadioService.class);Util.startForegroundService(MainActivity.this,intent);radioService.startPlayer();}}});buttonPause.setOnClickListener(newView.OnClickListener(){@OverridepublicvoidonClick(Viewview){buttonPause.setVisibility(View.INVISIBLE);buttonPlay.setVisibility(View.VISIBLE);HandlermHandler=newHandler(getMainLooper());mHandler.post(newRunnable(){@Overridepublicvoidrun(){dclmRadioService.pausePlayer();}});intstate2=radioService.player.getPlaybackState();}});buttonlive.setOnClickListener(newView.OnClickListener(){@OverridepublicvoidonClick(Viewv){dclmRadioService.prepare();buttonPlay.setVisibility(View.INVISIBLE);buttonPause.setVisibility(View.VISIBLE);}});}@OverrideprotectedvoidonResume(){super.onResume();SharedPreferencesprefs=getSharedPreferences(PREFRENCES,MODE_PRIVATE);intresume=prefs.getInt("audioIsPlaying",0);//0 is the default value.if(resume==10){booleanremain=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);}}}@OverrideprotectedvoidonStart(){super.onStart();// Bind to DCLMServiceIntentintent=newIntent(this,RadioService.class);bindService(intent,connection,BIND_AUTO_CREATE);}@OverrideprotectedvoidonStop(){super.onStop();if(audioIsPlaying!=5){unbindService(connection);mBound=false;}}@OverridepublicvoidonBackPressed(){IntentsetIntent=newIntent(Intent.ACTION_MAIN);setIntent.addCategory(Intent.CATEGORY_HOME);setIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);startActivity(setIntent);}@OverrideprotectedvoidonDestroy(){intent=newIntent(MainActivity.this,RadioService.class);stopService(intent);super.onDestroy();}}
Enter fullscreen modeExit 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

PackageManagerpackageManager=getPackageManager();// The results from AudioManager.getDevices can't be trusted unless the device// advertises FEATURE_AUDIO_OUTPUT.if(!packageManager.hasSystemFeature(PackageManager.FEATURE_AUDIO_OUTPUT)){returnfalse;}AudioManageraudioManager=(AudioManager)getSystemService(Context.AUDIO_SERVICE);AudioDeviceInfo[]devices=audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS);for(AudioDeviceInfodevice:devices){if(device.getType()==AudioDeviceInfo.TYPE_BUILTIN_SPEAKER){returntrue;}}
Enter fullscreen modeExit 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)

Subscribe
pic
Create template

Templates let you quickly answer FAQs or store snippets for re-use.

Dismiss
CollapseExpand
 
Sloan, the sloth mascot
Comment deleted

Are you sure you want to hide this comment? It will become hidden in your post, but will still be visible via the comment'spermalink.

For further actions, you may consider blocking this person and/orreporting abuse

A Christian | Android | iOS (Kotlin, Java, Swift, Flutter)Mobile Developer @investBamboo
  • Location
    Lagos, Nigeria
  • Work
    Mobile Developer at GiG Logistics
  • Joined

More fromOchornma Promise

DEV Community

We're a place where coders share, stay up-to-date and grow their careers.

Log in Create account

[8]ページ先頭

©2009-2025 Movatter.jp