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" />
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>
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" />
For the app to work both on square and round wearOS, you need this attribute boxedEdges on the wearOS layout file.
app:boxedEdges="all"
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>
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();
}
}
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();
}
}
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;
}
}
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)