Rework sleep timer so it no longer uses threads and clean up PlaybackServiceTaskManager (#7713)

Reworked sleep timer to no longer use threads, instead uses PlaybackService PlaybackPositionEvent which is fired while media is playing. We use this to calculate how much time is left of the sleep timer and send the proper events.
This commit is contained in:
eblis 2025-07-20 17:27:17 +03:00 committed by GitHub
parent c5cec07b0e
commit 55d3b743d1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 204 additions and 245 deletions

View File

@ -5,13 +5,10 @@ import androidx.test.platform.app.InstrumentationRegistry;
import androidx.test.annotation.UiThreadTest;
import androidx.test.filters.LargeTest;
import de.danoeh.antennapod.event.playback.SleepTimerUpdatedEvent;
import de.danoeh.antennapod.playback.service.internal.PlaybackServiceTaskManager;
import de.danoeh.antennapod.storage.preferences.SleepTimerPreferences;
import de.danoeh.antennapod.storage.database.PodDBAdapter;
import de.danoeh.antennapod.ui.widget.WidgetUpdater;
import org.greenrobot.eventbus.EventBus;
import org.greenrobot.eventbus.Subscribe;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
@ -28,7 +25,6 @@ import de.danoeh.antennapod.model.playback.Playable;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;
/**
* Test class for PlaybackServiceTaskManager
@ -190,7 +186,6 @@ public class PlaybackServiceTaskManagerTest {
pstm.cancelAllTasks();
assertFalse(pstm.isPositionSaverActive());
assertFalse(pstm.isWidgetUpdaterActive());
assertFalse(pstm.isSleepTimerActive());
pstm.shutdown();
}
@ -201,82 +196,9 @@ public class PlaybackServiceTaskManagerTest {
PlaybackServiceTaskManager pstm = new PlaybackServiceTaskManager(c, defaultPSTM);
pstm.startWidgetUpdater();
pstm.startPositionSaver();
pstm.setSleepTimer(100000);
pstm.cancelAllTasks();
assertFalse(pstm.isPositionSaverActive());
assertFalse(pstm.isWidgetUpdaterActive());
assertFalse(pstm.isSleepTimerActive());
pstm.shutdown();
}
@Test
@UiThreadTest
public void testSetSleepTimer() throws InterruptedException {
final Context c = InstrumentationRegistry.getInstrumentation().getTargetContext();
final long TIME = 2000;
final long TIMEOUT = 2 * TIME;
final CountDownLatch countDownLatch = new CountDownLatch(1);
Object timerReceiver = new Object() {
@Subscribe
public void sleepTimerUpdate(SleepTimerUpdatedEvent event) {
if (countDownLatch.getCount() == 0) {
fail();
}
countDownLatch.countDown();
}
};
EventBus.getDefault().register(timerReceiver);
PlaybackServiceTaskManager pstm = new PlaybackServiceTaskManager(c, defaultPSTM);
pstm.setSleepTimer(TIME);
countDownLatch.await(TIMEOUT, TimeUnit.MILLISECONDS);
EventBus.getDefault().unregister(timerReceiver);
pstm.shutdown();
}
@Test
@UiThreadTest
public void testDisableSleepTimer() throws InterruptedException {
final Context c = InstrumentationRegistry.getInstrumentation().getTargetContext();
final long TIME = 5000;
final long TIMEOUT = 2 * TIME;
final CountDownLatch countDownLatch = new CountDownLatch(1);
Object timerReceiver = new Object() {
@Subscribe
public void sleepTimerUpdate(SleepTimerUpdatedEvent event) {
if (event.isOver()) {
countDownLatch.countDown();
} else if (event.getTimeLeft() == 1) {
fail("Arrived at 1 but should have been cancelled");
}
}
};
PlaybackServiceTaskManager pstm = new PlaybackServiceTaskManager(c, defaultPSTM);
EventBus.getDefault().register(timerReceiver);
pstm.setSleepTimer(TIME);
pstm.disableSleepTimer();
assertFalse(countDownLatch.await(TIMEOUT, TimeUnit.MILLISECONDS));
pstm.shutdown();
EventBus.getDefault().unregister(timerReceiver);
}
@Test
@UiThreadTest
public void testIsSleepTimerActivePositive() {
final Context c = InstrumentationRegistry.getInstrumentation().getTargetContext();
PlaybackServiceTaskManager pstm = new PlaybackServiceTaskManager(c, defaultPSTM);
pstm.setSleepTimer(1000);
assertTrue(pstm.isSleepTimerActive());
pstm.shutdown();
}
@Test
@UiThreadTest
public void testIsSleepTimerActiveNegative() {
final Context c = InstrumentationRegistry.getInstrumentation().getTargetContext();
PlaybackServiceTaskManager pstm = new PlaybackServiceTaskManager(c, defaultPSTM);
pstm.setSleepTimer(10000);
pstm.disableSleepTimer();
assertFalse(pstm.isSleepTimerActive());
pstm.shutdown();
}

View File

@ -201,7 +201,7 @@ public class SleepTimerDialog extends DialogFragment {
chAutoEnable.setText(text);
}
@Subscribe(threadMode = ThreadMode.MAIN)
@Subscribe(threadMode = ThreadMode.MAIN, sticky = true)
@SuppressWarnings("unused")
public void timerUpdated(SleepTimerUpdatedEvent event) {
timeDisplay.setVisibility(event.isOver() || event.isCancelled() ? View.GONE : View.VISIBLE);

View File

@ -55,6 +55,7 @@ import androidx.media.MediaBrowserServiceCompat;
import de.danoeh.antennapod.event.PlayerStatusEvent;
import de.danoeh.antennapod.net.sync.serviceinterface.SynchronizationQueue;
import de.danoeh.antennapod.playback.service.internal.ClockSleepTimer;
import de.danoeh.antennapod.playback.service.internal.LocalPSMP;
import de.danoeh.antennapod.playback.service.internal.PlayableUtils;
import de.danoeh.antennapod.playback.service.internal.PlaybackServiceNotificationBuilder;
@ -80,7 +81,7 @@ import de.danoeh.antennapod.storage.preferences.PlaybackPreferences;
import de.danoeh.antennapod.storage.preferences.SleepTimerPreferences;
import de.danoeh.antennapod.storage.database.DBReader;
import de.danoeh.antennapod.storage.database.DBWriter;
import de.danoeh.antennapod.playback.service.internal.PlaybackServiceTaskManager.SleepTimer;
import de.danoeh.antennapod.playback.service.internal.SleepTimer;
import de.danoeh.antennapod.ui.common.IntentUtils;
import de.danoeh.antennapod.net.common.NetworkUtils;
import de.danoeh.antennapod.event.MessageEvent;
@ -159,6 +160,7 @@ public class PlaybackService extends MediaBrowserServiceCompat {
private PlaybackServiceMediaPlayer mediaPlayer;
private PlaybackServiceTaskManager taskManager;
private SleepTimer sleepTimer;
private PlaybackServiceStateManager stateManager;
private Disposable positionEventTimer;
private PlaybackServiceNotificationBuilder notificationBuilder;
@ -676,7 +678,6 @@ public class PlaybackService extends MediaBrowserServiceCompat {
} else {
return false;
}
taskManager.restartSleepTimer();
return true;
case KeyEvent.KEYCODE_MEDIA_PLAY:
if (status == PlayerStatus.PAUSED || status == PlayerStatus.PREPARED) {
@ -689,7 +690,6 @@ public class PlaybackService extends MediaBrowserServiceCompat {
} else {
return false;
}
taskManager.restartSleepTimer();
return true;
case KeyEvent.KEYCODE_MEDIA_PAUSE:
if (status == PlayerStatus.PLAYING) {
@ -1188,11 +1188,25 @@ public class PlaybackService extends MediaBrowserServiceCompat {
public void setSleepTimer(long waitingTime) {
Log.d(TAG, "Setting sleep timer to " + waitingTime + " milliseconds");
taskManager.setSleepTimer(waitingTime);
if (waitingTime <= 0) {
throw new IllegalArgumentException("Waiting time <= 0");
}
Log.d(TAG, "Setting sleep timer to " + waitingTime + " milliseconds or episodes");
if (sleepTimerActive()) {
sleepTimer.updateRemainingTime(waitingTime);
} else {
sleepTimer = new ClockSleepTimer(getApplicationContext());
sleepTimer.start(waitingTime);
}
}
public void disableSleepTimer() {
taskManager.disableSleepTimer();
if (sleepTimerActive()) {
Log.d(TAG, "Disabling sleep timer");
sleepTimer.stop();
}
sleepTimer = null;
}
private void sendNotificationBroadcast(int type, int code) {
@ -1473,11 +1487,15 @@ public class PlaybackService extends MediaBrowserServiceCompat {
}
public boolean sleepTimerActive() {
return taskManager.isSleepTimerActive();
return sleepTimer != null && sleepTimer.isActive();
}
public long getSleepTimerTimeLeft() {
return taskManager.getSleepTimerTimeLeft();
if (sleepTimerActive()) {
return sleepTimer.getTimeLeft();
} else {
return 0;
}
}
private void bluetoothNotifyChange(PlaybackServiceMediaPlayer.PSMPInfo info, String whatChanged) {
@ -1661,12 +1679,10 @@ public class PlaybackService extends MediaBrowserServiceCompat {
public void resume() {
mediaPlayer.resume();
taskManager.restartSleepTimer();
}
public void prepare() {
mediaPlayer.prepare();
taskManager.restartSleepTimer();
}
public void pause(boolean abandonAudioFocus, boolean reinit) {

View File

@ -0,0 +1,137 @@
package de.danoeh.antennapod.playback.service.internal;
import android.content.Context;
import android.os.Build;
import android.os.VibrationEffect;
import android.os.Vibrator;
import android.os.VibratorManager;
import android.util.Log;
import org.greenrobot.eventbus.EventBus;
import org.greenrobot.eventbus.Subscribe;
import org.greenrobot.eventbus.ThreadMode;
import de.danoeh.antennapod.event.playback.PlaybackPositionEvent;
import de.danoeh.antennapod.event.playback.SleepTimerUpdatedEvent;
import de.danoeh.antennapod.storage.preferences.SleepTimerPreferences;
public class ClockSleepTimer implements SleepTimer {
private static final String TAG = "ClockSleepTimer";
private final Context context;
private long initialWaitingTime;
private long timeLeft;
private boolean isRunning = false;
private long lastTick = 0;
private boolean hasVibrated = false;
private ShakeListener shakeListener;
public ClockSleepTimer(final Context context) {
this.context = context;
}
@Subscribe(threadMode = ThreadMode.MAIN)
@SuppressWarnings("unused")
public void playbackPositionUpdate(PlaybackPositionEvent playbackPositionEvent) {
Log.d(TAG, "playback position updated");
long now = System.currentTimeMillis();
long timeSinceLastTick = now - lastTick;
lastTick = now;
if (timeSinceLastTick > 10 * 1000) {
return; // Ticks should arrive every second. If they didn't, playback was paused for a while.
}
timeLeft -= timeSinceLastTick;
EventBus.getDefault().postSticky(SleepTimerUpdatedEvent.updated(timeLeft));
if (timeLeft < NOTIFICATION_THRESHOLD) {
notifyAboutExpiry();
}
if (timeLeft <= 0) {
Log.d(TAG, "Sleep timer expired");
stop();
}
}
protected void vibrate() {
final Vibrator vibrator;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
VibratorManager vibratorManager = (VibratorManager)
context.getSystemService(Context.VIBRATOR_MANAGER_SERVICE);
vibrator = vibratorManager.getDefaultVibrator();
} else {
vibrator = (Vibrator) context.getSystemService(Context.VIBRATOR_SERVICE);
}
if (vibrator == null) {
return;
}
final int duration = 500;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
vibrator.vibrate(VibrationEffect.createOneShot(duration, VibrationEffect.DEFAULT_AMPLITUDE));
} else {
vibrator.vibrate(duration);
}
}
protected void notifyAboutExpiry() {
Log.d(TAG, "Sleep timer is about to expire");
if (SleepTimerPreferences.vibrate() && !hasVibrated) {
vibrate();
hasVibrated = true;
}
// start listening for shakes if shake to reset is enabled
if (shakeListener == null && SleepTimerPreferences.shakeToReset()) {
shakeListener = new ShakeListener(getContext(), this);
}
}
@Override
public boolean isActive() {
return isRunning && timeLeft > 0;
}
public void start(long initialWaitingTime) {
this.initialWaitingTime = initialWaitingTime;
this.timeLeft = initialWaitingTime;
// make sure we've registered for events first
EventBus.getDefault().register(this);
final long left = getTimeLeft();
EventBus.getDefault().post(SleepTimerUpdatedEvent.justEnabled(left));
lastTick = System.currentTimeMillis();
EventBus.getDefault().postSticky(SleepTimerUpdatedEvent.updated(timeLeft));
isRunning = true;
}
@Override
public void stop() {
timeLeft = 0;
EventBus.getDefault().unregister(this);
if (shakeListener != null) {
shakeListener.pause();
}
shakeListener = null;
EventBus.getDefault().postSticky(SleepTimerUpdatedEvent.cancelled());
}
protected Context getContext() {
return context;
}
@Override
public long getTimeLeft() {
return timeLeft;
}
@Override
public void updateRemainingTime(long waitingTimeOrEpisodes) {
this.timeLeft = waitingTimeOrEpisodes;
}
@Override
public void reset() {
updateRemainingTime(initialWaitingTime);
}
}

View File

@ -3,16 +3,12 @@ package de.danoeh.antennapod.playback.service.internal;
import android.content.Context;
import android.os.Handler;
import android.os.Looper;
import android.os.Vibrator;
import androidx.annotation.NonNull;
import android.util.Log;
import de.danoeh.antennapod.event.playback.SleepTimerUpdatedEvent;
import de.danoeh.antennapod.storage.preferences.SleepTimerPreferences;
import de.danoeh.antennapod.ui.chapters.ChapterUtils;
import de.danoeh.antennapod.ui.widget.WidgetUpdater;
import io.reactivex.disposables.Disposable;
import org.greenrobot.eventbus.EventBus;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.ScheduledThreadPoolExecutor;
@ -49,11 +45,8 @@ public class PlaybackServiceTaskManager {
private ScheduledFuture<?> positionSaverFuture;
private ScheduledFuture<?> widgetUpdaterFuture;
private ScheduledFuture<?> sleepTimerFuture;
private volatile Disposable chapterLoaderFuture;
private SleepTimer sleepTimer;
private final Context context;
private final PSTMCallback callback;
@ -134,69 +127,6 @@ public class PlaybackServiceTaskManager {
}
}
/**
* Starts a new sleep timer with the given waiting time. If another sleep timer is already active, it will be
* cancelled first.
* After waitingTime has elapsed, onSleepTimerExpired() will be called.
*
* @throws java.lang.IllegalArgumentException if waitingTime <= 0
*/
public synchronized void setSleepTimer(long waitingTime) {
if (waitingTime <= 0) {
throw new IllegalArgumentException("Waiting time <= 0");
}
Log.d(TAG, "Setting sleep timer to " + waitingTime + " milliseconds");
if (isSleepTimerActive()) {
sleepTimerFuture.cancel(true);
}
sleepTimer = new SleepTimer(waitingTime);
sleepTimerFuture = schedExecutor.schedule(sleepTimer, 0, TimeUnit.MILLISECONDS);
EventBus.getDefault().post(SleepTimerUpdatedEvent.justEnabled(waitingTime));
}
/**
* Returns true if the sleep timer is currently active.
*/
public synchronized boolean isSleepTimerActive() {
return sleepTimer != null
&& sleepTimerFuture != null
&& !sleepTimerFuture.isCancelled()
&& !sleepTimerFuture.isDone()
&& sleepTimer.getWaitingTime() > 0;
}
/**
* Disables the sleep timer. If the sleep timer is not active, nothing will happen.
*/
public synchronized void disableSleepTimer() {
if (isSleepTimerActive()) {
Log.d(TAG, "Disabling sleep timer");
sleepTimer.cancel();
}
}
/**
* Restarts the sleep timer. If the sleep timer is not active, nothing will happen.
*/
public synchronized void restartSleepTimer() {
if (isSleepTimerActive()) {
Log.d(TAG, "Restarting sleep timer");
sleepTimer.restart();
}
}
/**
* Returns the current sleep timer time or 0 if the sleep timer is not active.
*/
public synchronized long getSleepTimerTimeLeft() {
if (isSleepTimerActive()) {
return sleepTimer.getWaitingTime();
} else {
return 0;
}
}
/**
* Returns true if the widget updater is currently running.
*/
@ -244,7 +174,6 @@ public class PlaybackServiceTaskManager {
public synchronized void cancelAllTasks() {
cancelPositionSaver();
cancelWidgetUpdater();
disableSleepTimer();
if (chapterLoaderFuture != null) {
chapterLoaderFuture.dispose();
@ -272,89 +201,6 @@ public class PlaybackServiceTaskManager {
}
}
/**
* Sleeps for a given time and then pauses playback.
*/
public class SleepTimer implements Runnable {
private static final String TAG = "SleepTimer";
private static final long UPDATE_INTERVAL = 1000L;
public static final long NOTIFICATION_THRESHOLD = 10000;
private boolean hasVibrated = false;
private final long waitingTime;
private long timeLeft;
private ShakeListener shakeListener;
public SleepTimer(long waitingTime) {
super();
this.waitingTime = waitingTime;
this.timeLeft = waitingTime;
}
@Override
public void run() {
Log.d(TAG, "Starting");
long lastTick = System.currentTimeMillis();
EventBus.getDefault().post(SleepTimerUpdatedEvent.updated(timeLeft));
while (timeLeft > 0) {
try {
Thread.sleep(UPDATE_INTERVAL);
} catch (InterruptedException e) {
Log.d(TAG, "Thread was interrupted while waiting");
e.printStackTrace();
break;
}
long now = System.currentTimeMillis();
timeLeft -= now - lastTick;
lastTick = now;
EventBus.getDefault().post(SleepTimerUpdatedEvent.updated(timeLeft));
if (timeLeft < NOTIFICATION_THRESHOLD) {
Log.d(TAG, "Sleep timer is about to expire");
if (SleepTimerPreferences.vibrate() && !hasVibrated) {
Vibrator v = (Vibrator) context.getSystemService(Context.VIBRATOR_SERVICE);
if (v != null) {
v.vibrate(500);
hasVibrated = true;
}
}
if (shakeListener == null && SleepTimerPreferences.shakeToReset()) {
shakeListener = new ShakeListener(context, this);
}
}
if (timeLeft <= 0) {
Log.d(TAG, "Sleep timer expired");
if (shakeListener != null) {
shakeListener.pause();
shakeListener = null;
}
hasVibrated = false;
}
}
}
public long getWaitingTime() {
return timeLeft;
}
public void restart() {
EventBus.getDefault().post(SleepTimerUpdatedEvent.cancelled());
setSleepTimer(waitingTime);
if (shakeListener != null) {
shakeListener.pause();
shakeListener = null;
}
}
public void cancel() {
sleepTimerFuture.cancel(true);
if (shakeListener != null) {
shakeListener.pause();
}
EventBus.getDefault().post(SleepTimerUpdatedEvent.cancelled());
}
}
public interface PSTMCallback {
void positionSaverTick();

View File

@ -16,10 +16,10 @@ public class ShakeListener implements SensorEventListener {
private Sensor mAccelerometer;
private SensorManager mSensorMgr;
private final PlaybackServiceTaskManager.SleepTimer mSleepTimer;
private final SleepTimer mSleepTimer;
private final Context mContext;
public ShakeListener(Context context, PlaybackServiceTaskManager.SleepTimer sleepTimer) {
public ShakeListener(Context context, SleepTimer sleepTimer) {
mContext = context;
mSleepTimer = sleepTimer;
resume();
@ -75,7 +75,7 @@ public class ShakeListener implements SensorEventListener {
double gForce = Math.sqrt(gX * gX + gY * gY + gZ * gZ);
if (gForce > 2.25) {
Log.d(TAG, "Detected shake " + gForce);
mSleepTimer.restart();
mSleepTimer.reset();
vibrate();
}
}

View File

@ -0,0 +1,38 @@
package de.danoeh.antennapod.playback.service.internal;
public interface SleepTimer {
long NOTIFICATION_THRESHOLD = 10000;
/**
* @return Returns time left for this timer, in millis
*/
long getTimeLeft();
/**
* Starts the sleep timer.
* @param initialWaitingTime The waiting time for the sleep timer, either episodes or duration
*/
void start(long initialWaitingTime);
/**
* Cancels (stops) current sleep timer forever, cannot be restarted.
*/
void stop();
/**
* Update sleep timer with new waiting time
* @param waitingTimeOrEpisodes Waiting time in millis or episode count
*/
void updateRemainingTime(long waitingTimeOrEpisodes);
/**
* Resets sleep timer to original duration.
*/
void reset();
/**
* @return True if sleep timer is active, false otherwise
*/
boolean isActive();
}