Reduce coupling between widget and playback service

Instead of binding to the service, pass the required data. This also
ensures that the widget is updated instantly when calling from
PlaybackService. JobService had the problem that the OS sometimes
took some seconds before actually executing the job.
This commit is contained in:
ByteHamster 2021-01-26 12:40:16 +01:00
parent f3bf708e26
commit b6f72f8847
9 changed files with 146 additions and 151 deletions

View File

@ -6,6 +6,7 @@ import androidx.test.annotation.UiThreadTest;
import androidx.test.filters.LargeTest;
import de.danoeh.antennapod.core.preferences.SleepTimerPreferences;
import de.danoeh.antennapod.core.widget.WidgetUpdater;
import org.awaitility.Awaitility;
import org.greenrobot.eventbus.EventBus;
import org.junit.After;
@ -187,8 +188,8 @@ public class PlaybackServiceTaskManagerTest {
}
@Override
public void onWidgetUpdaterTick() {
public WidgetUpdater.WidgetState requestWidgetState() {
return null;
}
@Override
@ -248,8 +249,9 @@ public class PlaybackServiceTaskManagerTest {
}
@Override
public void onWidgetUpdaterTick() {
public WidgetUpdater.WidgetState requestWidgetState() {
countDownLatch.countDown();
return null;
}
@Override
@ -348,8 +350,8 @@ public class PlaybackServiceTaskManagerTest {
}
@Override
public void onWidgetUpdaterTick() {
public WidgetUpdater.WidgetState requestWidgetState() {
return null;
}
@Override
@ -391,8 +393,8 @@ public class PlaybackServiceTaskManagerTest {
}
@Override
public void onWidgetUpdaterTick() {
public WidgetUpdater.WidgetState requestWidgetState() {
return null;
}
@Override
@ -449,8 +451,8 @@ public class PlaybackServiceTaskManagerTest {
}
@Override
public void onWidgetUpdaterTick() {
public WidgetUpdater.WidgetState requestWidgetState() {
return null;
}
@Override

View File

@ -19,7 +19,7 @@ import androidx.core.content.ContextCompat;
import de.danoeh.antennapod.R;
import de.danoeh.antennapod.core.preferences.UserPreferences;
import de.danoeh.antennapod.core.receiver.PlayerWidget;
import de.danoeh.antennapod.core.service.PlayerWidgetJobService;
import de.danoeh.antennapod.core.widget.WidgetUpdaterJobService;
public class WidgetConfigActivity extends AppCompatActivity {
private int appWidgetId = AppWidgetManager.INVALID_APPWIDGET_ID;
@ -127,7 +127,7 @@ public class WidgetConfigActivity extends AppCompatActivity {
resultValue.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId);
setResult(RESULT_OK, resultValue);
finish();
PlayerWidgetJobService.updateWidget(this);
WidgetUpdaterJobService.performBackgroundUpdate(this);
}
private int getColorWithAlpha(int color, int opacity) {

View File

@ -45,6 +45,11 @@
android:label="@string/feed_update_receiver_name"
android:exported="true"
tools:ignore="ExportedReceiver" /> <!-- allow feeds update to be triggered by external apps -->
<service
android:name=".widget.WidgetUpdaterJobService"
android:permission="android.permission.BIND_JOB_SERVICE"
android:exported="true"/>
</application>
</manifest>

View File

@ -9,7 +9,7 @@ import android.util.Log;
import java.util.Arrays;
import de.danoeh.antennapod.core.service.PlayerWidgetJobService;
import de.danoeh.antennapod.core.widget.WidgetUpdaterJobService;
public class PlayerWidget extends AppWidgetProvider {
private static final String TAG = "PlayerWidget";
@ -25,7 +25,7 @@ public class PlayerWidget extends AppWidgetProvider {
public void onReceive(Context context, Intent intent) {
Log.d(TAG, "onReceive");
super.onReceive(context, intent);
PlayerWidgetJobService.updateWidget(context);
WidgetUpdaterJobService.performBackgroundUpdate(context);
}
@Override
@ -33,13 +33,14 @@ public class PlayerWidget extends AppWidgetProvider {
super.onEnabled(context);
Log.d(TAG, "Widget enabled");
setEnabled(context, true);
PlayerWidgetJobService.updateWidget(context);
WidgetUpdaterJobService.performBackgroundUpdate(context);
}
@Override
public void onUpdate(Context context, AppWidgetManager appWidgetManager, int[] appWidgetIds) {
Log.d(TAG, "onUpdate() called with: " + "context = [" + context + "], appWidgetManager = [" + appWidgetManager + "], appWidgetIds = [" + Arrays.toString(appWidgetIds) + "]");
PlayerWidgetJobService.updateWidget(context);
Log.d(TAG, "onUpdate() called with: " + "context = [" + context + "], appWidgetManager = ["
+ appWidgetManager + "], appWidgetIds = [" + Arrays.toString(appWidgetIds) + "]");
WidgetUpdaterJobService.performBackgroundUpdate(context);
}
@Override

View File

@ -69,7 +69,6 @@ import de.danoeh.antennapod.core.preferences.PlaybackPreferences;
import de.danoeh.antennapod.core.preferences.SleepTimerPreferences;
import de.danoeh.antennapod.core.preferences.UserPreferences;
import de.danoeh.antennapod.core.receiver.MediaButtonReceiver;
import de.danoeh.antennapod.core.service.PlayerWidgetJobService;
import de.danoeh.antennapod.core.storage.DBReader;
import de.danoeh.antennapod.core.storage.DBTasks;
import de.danoeh.antennapod.core.storage.DBWriter;
@ -81,6 +80,7 @@ import de.danoeh.antennapod.core.util.gui.NotificationUtils;
import de.danoeh.antennapod.core.util.playback.ExternalMedia;
import de.danoeh.antennapod.core.util.playback.Playable;
import de.danoeh.antennapod.core.util.playback.PlaybackServiceStarter;
import de.danoeh.antennapod.core.widget.WidgetUpdater;
import io.reactivex.Observable;
import io.reactivex.Single;
import io.reactivex.android.schedulers.AndroidSchedulers;
@ -801,8 +801,9 @@ public class PlaybackService extends MediaBrowserServiceCompat {
}
@Override
public void onWidgetUpdaterTick() {
PlayerWidgetJobService.updateWidget(getBaseContext());
public WidgetUpdater.WidgetState requestWidgetState() {
return new WidgetUpdater.WidgetState(getPlayable(), getStatus(),
getCurrentPosition(), getDuration(), getCurrentPlaybackSpeed());
}
@Override
@ -873,9 +874,9 @@ public class PlaybackService extends MediaBrowserServiceCompat {
}
IntentUtils.sendLocalBroadcast(getApplicationContext(), ACTION_PLAYER_STATUS_CHANGED);
PlayerWidgetJobService.updateWidget(getBaseContext());
bluetoothNotifyChange(newInfo, AVRCP_ACTION_PLAYER_STATUS_CHANGED);
bluetoothNotifyChange(newInfo, AVRCP_ACTION_META_CHANGED);
taskManager.requestWidgetUpdate();
}
@Override

View File

@ -8,6 +8,7 @@ import androidx.annotation.NonNull;
import android.util.Log;
import de.danoeh.antennapod.core.preferences.SleepTimerPreferences;
import de.danoeh.antennapod.core.widget.WidgetUpdater;
import io.reactivex.disposables.Disposable;
import org.greenrobot.eventbus.EventBus;
import org.greenrobot.eventbus.Subscribe;
@ -199,17 +200,28 @@ public class PlaybackServiceTaskManager {
*/
public synchronized void startWidgetUpdater() {
if (!isWidgetUpdaterActive() && !schedExecutor.isShutdown()) {
Runnable widgetUpdater = callback::onWidgetUpdaterTick;
Runnable widgetUpdater = this::requestWidgetUpdate;
widgetUpdater = useMainThreadIfNecessary(widgetUpdater);
widgetUpdaterFuture = schedExecutor.scheduleWithFixedDelay(widgetUpdater, WIDGET_UPDATER_NOTIFICATION_INTERVAL,
WIDGET_UPDATER_NOTIFICATION_INTERVAL, TimeUnit.MILLISECONDS);
widgetUpdaterFuture = schedExecutor.scheduleWithFixedDelay(widgetUpdater,
WIDGET_UPDATER_NOTIFICATION_INTERVAL, WIDGET_UPDATER_NOTIFICATION_INTERVAL, TimeUnit.MILLISECONDS);
Log.d(TAG, "Started WidgetUpdater");
} else {
Log.d(TAG, "Call to startWidgetUpdater was ignored.");
}
}
/**
* Retrieves information about the widget state in the calling thread and then displays it in a background thread.
*/
public synchronized void requestWidgetUpdate() {
WidgetUpdater.WidgetState state = callback.requestWidgetState();
if (!schedExecutor.isShutdown()) {
schedExecutor.execute(() -> WidgetUpdater.updateWidget(context, state));
} else {
Log.d(TAG, "Call to requestWidgetUpdate was ignored.");
}
}
/**
* Starts a new sleep timer with the given waiting time. If another sleep timer is already active, it will be
* cancelled first.
@ -464,7 +476,7 @@ public class PlaybackServiceTaskManager {
void onSleepTimerReset();
void onWidgetUpdaterTick();
WidgetUpdater.WidgetState requestWidgetState();
void onChapterLoaded(Playable media);
}

View File

@ -21,6 +21,7 @@ import java.util.List;
*/
public interface Playable extends Parcelable,
ShownotesProvider, ImageResource {
public static final int INVALID_TIME = -1;
/**
* Save information about the playable in a preference so that it can be

View File

@ -1,17 +1,13 @@
package de.danoeh.antennapod.core.service;
package de.danoeh.antennapod.core.widget;
import android.app.PendingIntent;
import android.appwidget.AppWidgetManager;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.content.ServiceConnection;
import android.content.SharedPreferences;
import android.graphics.Bitmap;
import android.os.Bundle;
import android.os.IBinder;
import androidx.annotation.NonNull;
import androidx.core.app.SafeJobIntentService;
import android.util.Log;
import android.view.KeyEvent;
import android.view.View;
@ -24,7 +20,6 @@ import java.util.concurrent.TimeUnit;
import de.danoeh.antennapod.core.R;
import de.danoeh.antennapod.core.glide.ApGlideSettings;
import de.danoeh.antennapod.core.feed.util.PlaybackSpeedUtils;
import de.danoeh.antennapod.core.receiver.MediaButtonReceiver;
import de.danoeh.antennapod.core.receiver.PlayerWidget;
import de.danoeh.antennapod.core.service.playback.PlaybackService;
@ -35,107 +30,65 @@ import de.danoeh.antennapod.core.util.TimeSpeedConverter;
import de.danoeh.antennapod.core.util.playback.Playable;
/**
* Updates the state of the player widget
* Updates the state of the player widget.
*/
public class PlayerWidgetJobService extends SafeJobIntentService {
public abstract class WidgetUpdater {
private static final String TAG = "WidgetUpdater";
private static final String TAG = "PlayerWidgetJobService";
public static class WidgetState {
final Playable media;
final PlayerStatus status;
final int position;
final int duration;
final float playbackSpeed;
private PlaybackService playbackService;
private final Object waitForService = new Object();
private final Object waitUsingService = new Object();
private static final int JOB_ID = -17001;
public static void updateWidget(Context context) {
enqueueWork(context, PlayerWidgetJobService.class, JOB_ID, new Intent(context, PlayerWidgetJobService.class));
}
@Override
protected void onHandleWork(@NonNull Intent intent) {
if (!PlayerWidget.isEnabled(getApplicationContext())) {
return;
public WidgetState(Playable media, PlayerStatus status, int position, int duration, float playbackSpeed) {
this.media = media;
this.status = status;
this.position = position;
this.duration = duration;
this.playbackSpeed = playbackSpeed;
}
synchronized (waitForService) {
if (PlaybackService.isRunning && playbackService == null) {
bindService(new Intent(this, PlaybackService.class), mConnection, 0);
while (playbackService == null) {
try {
waitForService.wait();
} catch (InterruptedException e) {
return;
}
}
}
}
synchronized (waitUsingService) {
updateViews();
}
if (playbackService != null) {
try {
unbindService(mConnection);
} catch (IllegalArgumentException e) {
Log.w(TAG, "IllegalArgumentException when trying to unbind service");
}
public WidgetState(PlayerStatus status) {
this(null, status, Playable.INVALID_TIME, Playable.INVALID_TIME, 1.0f);
}
}
/**
* Returns number of cells needed for given size of the widget.
*
* @param size Widget size in dp.
* @return Size in number of cells.
* Update the widgets with the given parameters. Must be called in a background thread.
*/
private static int getCellsForSize(int size) {
int n = 2;
while (70 * n - 30 < size) {
++n;
public static void updateWidget(Context context, WidgetState widgetState) {
if (!PlayerWidget.isEnabled(context) || widgetState == null) {
return;
}
return n - 1;
}
private void updateViews() {
ComponentName playerWidget = new ComponentName(this, PlayerWidget.class);
AppWidgetManager manager = AppWidgetManager.getInstance(this);
ComponentName playerWidget = new ComponentName(context, PlayerWidget.class);
AppWidgetManager manager = AppWidgetManager.getInstance(context);
int[] widgetIds = manager.getAppWidgetIds(playerWidget);
final PendingIntent startMediaPlayer = PendingIntent.getActivity(this, R.id.pending_intent_player_activity,
PlaybackService.getPlayerActivityIntent(this), PendingIntent.FLAG_UPDATE_CURRENT);
final PendingIntent startMediaPlayer = PendingIntent.getActivity(context, R.id.pending_intent_player_activity,
PlaybackService.getPlayerActivityIntent(context), PendingIntent.FLAG_UPDATE_CURRENT);
RemoteViews views;
views = new RemoteViews(getPackageName(), R.layout.player_widget);
views = new RemoteViews(context.getPackageName(), R.layout.player_widget);
Playable media;
PlayerStatus status;
if (playbackService != null) {
media = playbackService.getPlayable();
status = playbackService.getStatus();
} else {
media = Playable.PlayableUtils.createInstanceFromPreferences(getApplicationContext());
status = PlayerStatus.STOPPED;
}
if (media != null) {
if (widgetState.media != null) {
Bitmap icon;
int iconSize = getResources().getDimensionPixelSize(android.R.dimen.app_icon_size);
int iconSize = context.getResources().getDimensionPixelSize(android.R.dimen.app_icon_size);
views.setOnClickPendingIntent(R.id.layout_left, startMediaPlayer);
views.setOnClickPendingIntent(R.id.imgvCover, startMediaPlayer);
try {
icon = Glide.with(PlayerWidgetJobService.this)
icon = Glide.with(context)
.asBitmap()
.load(ImageResourceUtils.getImageLocation(media))
.load(ImageResourceUtils.getImageLocation(widgetState.media))
.apply(RequestOptions.diskCacheStrategyOf(ApGlideSettings.AP_DISK_CACHE_STRATEGY))
.submit(iconSize, iconSize)
.get(500, TimeUnit.MILLISECONDS);
views.setImageViewBitmap(R.id.imgvCover, icon);
} catch (Throwable tr1) {
try {
icon = Glide.with(PlayerWidgetJobService.this)
icon = Glide.with(context)
.asBitmap()
.load(ImageResourceUtils.getFallbackImageLocation(media))
.load(ImageResourceUtils.getFallbackImageLocation(widgetState.media))
.apply(RequestOptions.diskCacheStrategyOf(ApGlideSettings.AP_DISK_CACHE_STRATEGY))
.submit(iconSize, iconSize)
.get(500, TimeUnit.MILLISECONDS);
@ -146,50 +99,44 @@ public class PlayerWidgetJobService extends SafeJobIntentService {
}
}
views.setTextViewText(R.id.txtvTitle, media.getEpisodeTitle());
views.setTextViewText(R.id.txtvTitle, widgetState.media.getEpisodeTitle());
views.setViewVisibility(R.id.txtvTitle, View.VISIBLE);
views.setViewVisibility(R.id.txtNoPlaying, View.GONE);
String progressString;
if (playbackService != null) {
progressString = getProgressString(playbackService.getCurrentPosition(),
playbackService.getDuration(), playbackService.getCurrentPlaybackSpeed());
} else {
progressString = getProgressString(media.getPosition(), media.getDuration(), PlaybackSpeedUtils.getCurrentPlaybackSpeed(media));
}
String progressString = getProgressString(widgetState.position,
widgetState.duration, widgetState.playbackSpeed);
if (progressString != null) {
views.setViewVisibility(R.id.txtvProgress, View.VISIBLE);
views.setTextViewText(R.id.txtvProgress, progressString);
}
if (status == PlayerStatus.PLAYING) {
if (widgetState.status == PlayerStatus.PLAYING) {
views.setImageViewResource(R.id.butPlay, R.drawable.ic_av_pause_white_48dp);
views.setContentDescription(R.id.butPlay, getString(R.string.pause_label));
views.setContentDescription(R.id.butPlay, context.getString(R.string.pause_label));
views.setImageViewResource(R.id.butPlayExtended, R.drawable.ic_av_pause_white_48dp);
views.setContentDescription(R.id.butPlayExtended, getString(R.string.pause_label));
views.setContentDescription(R.id.butPlayExtended, context.getString(R.string.pause_label));
} else {
views.setImageViewResource(R.id.butPlay, R.drawable.ic_av_play_white_48dp);
views.setContentDescription(R.id.butPlay, getString(R.string.play_label));
views.setContentDescription(R.id.butPlay, context.getString(R.string.play_label));
views.setImageViewResource(R.id.butPlayExtended, R.drawable.ic_av_play_white_48dp);
views.setContentDescription(R.id.butPlayExtended, getString(R.string.play_label));
views.setContentDescription(R.id.butPlayExtended, context.getString(R.string.play_label));
}
views.setOnClickPendingIntent(R.id.butPlay,
createMediaButtonIntent(KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE));
createMediaButtonIntent(context, KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE));
views.setOnClickPendingIntent(R.id.butPlayExtended,
createMediaButtonIntent(KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE));
createMediaButtonIntent(context, KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE));
views.setOnClickPendingIntent(R.id.butRew,
createMediaButtonIntent(KeyEvent.KEYCODE_MEDIA_REWIND));
createMediaButtonIntent(context, KeyEvent.KEYCODE_MEDIA_REWIND));
views.setOnClickPendingIntent(R.id.butFastForward,
createMediaButtonIntent(KeyEvent.KEYCODE_MEDIA_FAST_FORWARD));
createMediaButtonIntent(context, KeyEvent.KEYCODE_MEDIA_FAST_FORWARD));
views.setOnClickPendingIntent(R.id.butSkip,
createMediaButtonIntent(KeyEvent.KEYCODE_MEDIA_NEXT));
createMediaButtonIntent(context, KeyEvent.KEYCODE_MEDIA_NEXT));
} else {
// start the app if they click anything
views.setOnClickPendingIntent(R.id.layout_left, startMediaPlayer);
views.setOnClickPendingIntent(R.id.butPlay, startMediaPlayer);
views.setOnClickPendingIntent(R.id.butPlayExtended,
createMediaButtonIntent(KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE));
createMediaButtonIntent(context, KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE));
views.setViewVisibility(R.id.txtvProgress, View.GONE);
views.setViewVisibility(R.id.txtvTitle, View.GONE);
views.setViewVisibility(R.id.txtNoPlaying, View.VISIBLE);
@ -201,7 +148,7 @@ public class PlayerWidgetJobService extends SafeJobIntentService {
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.JELLY_BEAN) {
for (int id : widgetIds) {
Bundle options = manager.getAppWidgetOptions(id);
SharedPreferences prefs = getSharedPreferences(PlayerWidget.PREFS_NAME, Context.MODE_PRIVATE);
SharedPreferences prefs = context.getSharedPreferences(PlayerWidget.PREFS_NAME, Context.MODE_PRIVATE);
int minWidth = options.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH);
int columns = getCellsForSize(minWidth);
if (columns < 3) {
@ -232,18 +179,32 @@ public class PlayerWidgetJobService extends SafeJobIntentService {
}
/**
* Creates an intent which fakes a mediabutton press
* Returns number of cells needed for given size of the widget.
*
* @param size Widget size in dp.
* @return Size in number of cells.
*/
private PendingIntent createMediaButtonIntent(int eventCode) {
private static int getCellsForSize(int size) {
int n = 2;
while (70 * n - 30 < size) {
++n;
}
return n - 1;
}
/**
* Creates an intent which fakes a mediabutton press.
*/
private static PendingIntent createMediaButtonIntent(Context context, int eventCode) {
KeyEvent event = new KeyEvent(KeyEvent.ACTION_DOWN, eventCode);
Intent startingIntent = new Intent(getBaseContext(), MediaButtonReceiver.class);
Intent startingIntent = new Intent(context, MediaButtonReceiver.class);
startingIntent.setAction(MediaButtonReceiver.NOTIFY_BUTTON_RECEIVER);
startingIntent.putExtra(Intent.EXTRA_KEY_EVENT, event);
return PendingIntent.getBroadcast(this, eventCode, startingIntent, 0);
return PendingIntent.getBroadcast(context, eventCode, startingIntent, 0);
}
private String getProgressString(int position, int duration, float speed) {
private static String getProgressString(int position, int duration, float speed) {
if (position >= 0 && duration > 0) {
TimeSpeedConverter converter = new TimeSpeedConverter(speed);
position = converter.convert(position);
@ -254,24 +215,4 @@ public class PlayerWidgetJobService extends SafeJobIntentService {
return null;
}
}
private final ServiceConnection mConnection = new ServiceConnection() {
public void onServiceConnected(ComponentName className, IBinder service) {
Log.d(TAG, "Connection to service established");
if (service instanceof PlaybackService.LocalBinder) {
synchronized (waitForService) {
playbackService = ((PlaybackService.LocalBinder) service).getService();
waitForService.notifyAll();
}
}
}
@Override
public void onServiceDisconnected(ComponentName name) {
synchronized (waitUsingService) {
playbackService = null;
}
Log.d(TAG, "Disconnected from service");
}
};
}

View File

@ -0,0 +1,32 @@
package de.danoeh.antennapod.core.widget;
import android.content.Context;
import android.content.Intent;
import androidx.annotation.NonNull;
import androidx.core.app.SafeJobIntentService;
import de.danoeh.antennapod.core.feed.util.PlaybackSpeedUtils;
import de.danoeh.antennapod.core.service.playback.PlayerStatus;
import de.danoeh.antennapod.core.util.playback.Playable;
public class WidgetUpdaterJobService extends SafeJobIntentService {
private static final int JOB_ID = -17001;
/**
* Loads the current media from the database and updates the widget in a background job.
*/
public static void performBackgroundUpdate(Context context) {
enqueueWork(context, WidgetUpdaterJobService.class,
WidgetUpdaterJobService.JOB_ID, new Intent(context, WidgetUpdaterJobService.class));
}
@Override
protected void onHandleWork(@NonNull Intent intent) {
Playable media = Playable.PlayableUtils.createInstanceFromPreferences(getApplicationContext());
if (media != null) {
WidgetUpdater.updateWidget(this, new WidgetUpdater.WidgetState(media, PlayerStatus.STOPPED,
media.getPosition(), media.getDuration(), PlaybackSpeedUtils.getCurrentPlaybackSpeed(media)));
} else {
WidgetUpdater.updateWidget(this, new WidgetUpdater.WidgetState(PlayerStatus.STOPPED));
}
}
}