diff --git a/android/app/src/main/java/org/musicpd/Main.java b/android/app/src/main/java/org/musicpd/Main.java deleted file mode 100644 index 5f440b964..000000000 --- a/android/app/src/main/java/org/musicpd/Main.java +++ /dev/null @@ -1,338 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-or-later -// Copyright The Music Player Daemon Project - -package org.musicpd; - -import android.app.Notification; -import android.app.NotificationManager; -import android.app.PendingIntent; -import android.app.Service; -import android.content.BroadcastReceiver; -import android.content.Context; -import android.content.Intent; -import android.content.IntentFilter; -import android.media.AudioManager; -import android.os.Build; -import android.os.IBinder; -import android.os.Looper; -import android.os.PowerManager; -import android.os.RemoteCallbackList; -import android.os.RemoteException; -import android.util.Log; - -import androidx.annotation.OptIn; -import androidx.media3.common.util.UnstableApi; -import androidx.media3.session.MediaSession; - -import org.jetbrains.annotations.NotNull; -import org.musicpd.data.LoggingRepository; - -import java.lang.reflect.Constructor; -import java.lang.reflect.Method; -import java.util.Objects; - -import javax.inject.Inject; - -import dagger.hilt.android.AndroidEntryPoint; - -@AndroidEntryPoint -public class Main extends Service implements Runnable { - - private static final String TAG = "Main"; - private static final String WAKELOCK_TAG = "mpd:wakelockmain"; - - private static final int MAIN_STATUS_ERROR = -1; - private static final int MAIN_STATUS_STOPPED = 0; - private static final int MAIN_STATUS_STARTED = 1; - - private static final int MSG_SEND_STATUS = 0; - - private Thread mThread = null; - private int mStatus = MAIN_STATUS_STOPPED; - private boolean mAbort = false; - private String mError = null; - private final RemoteCallbackList<IMainCallback> mCallbacks = new RemoteCallbackList<IMainCallback>(); - private final IBinder mBinder = new MainStub(this); - private boolean mPauseOnHeadphonesDisconnect = false; - private PowerManager.WakeLock mWakelock = null; - - private MediaSession mMediaSession = null; - - @Inject - LoggingRepository logging; - - @NotNull - public static final String SHUTDOWN_ACTION = "org.musicpd.action.ShutdownMPD"; - - static class MainStub extends IMain.Stub { - private Main mService; - MainStub(Main service) { - mService = service; - } - public void start() { - mService.start(); - } - public void stop() { - mService.stop(); - } - public void setPauseOnHeadphonesDisconnect(boolean enabled) { - mService.setPauseOnHeadphonesDisconnect(enabled); - } - public void setWakelockEnabled(boolean enabled) { - mService.setWakelockEnabled(enabled); - } - public boolean isRunning() { - return mService.isRunning(); - } - public void registerCallback(IMainCallback cb) { - mService.registerCallback(cb); - } - public void unregisterCallback(IMainCallback cb) { - mService.unregisterCallback(cb); - } - } - - private synchronized void sendMessage(int what, int arg1, int arg2, Object obj) { - int i = mCallbacks.beginBroadcast(); - while (i > 0) { - i--; - final IMainCallback cb = mCallbacks.getBroadcastItem(i); - try { - switch (what) { - case MSG_SEND_STATUS: - switch (arg1) { - case MAIN_STATUS_ERROR: - cb.onError((String)obj); - break; - case MAIN_STATUS_STOPPED: - cb.onStopped(); - break; - case MAIN_STATUS_STARTED: - cb.onStarted(); - break; - } - break; - } - } catch (RemoteException e) { - } - } - mCallbacks.finishBroadcast(); - } - - private Bridge.LogListener mLogListener = new Bridge.LogListener() { - @Override - public void onLog(int priority, String msg) { - logging.addLogItem(priority, msg); - } - }; - - @Override - public IBinder onBind(Intent intent) { - return mBinder; - } - - @Override - public int onStartCommand(Intent intent, int flags, int startId) { - if (intent != null && Objects.equals(intent.getAction(), SHUTDOWN_ACTION)) { - stop(); - } else { - start(); - if (intent != null && intent.getBooleanExtra("wakelock", false)) - setWakelockEnabled(true); - } - return START_STICKY; - } - - @Override - public void run() { - if (!Loader.loaded) { - final String error = "Failed to load the native MPD libary.\n" + - "Report this problem to us, and include the following information:\n" + - "SUPPORTED_ABIS=" + String.join(", ", Build.SUPPORTED_ABIS) + "\n" + - "PRODUCT=" + Build.PRODUCT + "\n" + - "FINGERPRINT=" + Build.FINGERPRINT + "\n" + - "error=" + Loader.error; - setStatus(MAIN_STATUS_ERROR, error); - stopSelf(); - return; - } - synchronized (this) { - if (mAbort) - return; - setStatus(MAIN_STATUS_STARTED, null); - } - Bridge.run(this, mLogListener); - setStatus(MAIN_STATUS_STOPPED, null); - } - - private synchronized void setStatus(int status, String error) { - mStatus = status; - mError = error; - sendMessage(MSG_SEND_STATUS, mStatus, 0, mError); - } - - private Notification.Builder createNotificationBuilderWithChannel() { - final NotificationManager notificationManager = (NotificationManager) this.getSystemService(Context.NOTIFICATION_SERVICE); - if (notificationManager == null) - return null; - - final String id = "org.musicpd"; - final String name = "MPD service"; - final int importance = 3; /* NotificationManager.IMPORTANCE_DEFAULT */ - - try { - Class<?> ncClass = Class.forName("android.app.NotificationChannel"); - Constructor<?> ncCtor = ncClass.getConstructor(String.class, CharSequence.class, int.class); - Object nc = ncCtor.newInstance(id, name, importance); - - Method nmCreateNotificationChannelMethod = - NotificationManager.class.getMethod("createNotificationChannel", ncClass); - nmCreateNotificationChannelMethod.invoke(notificationManager, nc); - - Constructor nbCtor = Notification.Builder.class.getConstructor(Context.class, String.class); - return (Notification.Builder) nbCtor.newInstance(this, id); - } catch (Exception e) - { - Log.e(TAG, "error creating the NotificationChannel", e); - return null; - } - } - - @OptIn(markerClass = UnstableApi.class) - private void start() { - if (mThread != null) - return; - - IntentFilter filter = new IntentFilter(); - filter.addAction(AudioManager.ACTION_AUDIO_BECOMING_NOISY); - registerReceiver(new BroadcastReceiver() { - @Override - public void onReceive(Context context, Intent intent) { - if (!mPauseOnHeadphonesDisconnect) - return; - if (intent.getAction() == AudioManager.ACTION_AUDIO_BECOMING_NOISY) - pause(); - } - }, filter); - - final Intent mainIntent = new Intent(this, MainActivity.class); - mainIntent.setAction("android.intent.action.MAIN"); - mainIntent.addCategory("android.intent.category.LAUNCHER"); - final PendingIntent contentIntent = PendingIntent.getActivity(this, 0, - mainIntent, PendingIntent.FLAG_CANCEL_CURRENT | PendingIntent.FLAG_IMMUTABLE); - - Notification.Builder nBuilder; - if (Build.VERSION.SDK_INT >= 26 /* Build.VERSION_CODES.O */) - { - nBuilder = createNotificationBuilderWithChannel(); - if (nBuilder == null) - return; - } - else - nBuilder = new Notification.Builder(this); - - Notification notification = nBuilder.setContentTitle(getText(R.string.notification_title_mpd_running)) - .setContentText(getText(R.string.notification_text_mpd_running)) - .setSmallIcon(R.drawable.notification_icon) - .setContentIntent(contentIntent) - .build(); - - mThread = new Thread(this); - mThread.start(); - - MPDPlayer player = new MPDPlayer(Looper.getMainLooper()); - mMediaSession = new MediaSession.Builder(this, player).build(); - - startForeground(R.string.notification_title_mpd_running, notification); - startService(new Intent(this, Main.class)); - } - - private void stop() { - mMediaSession.release(); - mMediaSession = null; - if (mThread != null) { - if (mThread.isAlive()) { - synchronized (this) { - if (mStatus == MAIN_STATUS_STARTED) - Bridge.shutdown(); - else - mAbort = true; - } - } - try { - mThread.join(); - mThread = null; - mAbort = false; - } catch (InterruptedException ie) {} - } - setWakelockEnabled(false); - stopForeground(true); - stopSelf(); - } - - private void pause() { - if (mThread != null) { - if (mThread.isAlive()) { - synchronized (this) { - if (mStatus == MAIN_STATUS_STARTED) - Bridge.pause(); - } - } - } - } - - private void setPauseOnHeadphonesDisconnect(boolean enabled) { - mPauseOnHeadphonesDisconnect = enabled; - } - - private void setWakelockEnabled(boolean enabled) { - if (enabled && mWakelock == null) { - PowerManager pm = (PowerManager)getSystemService(Context.POWER_SERVICE); - mWakelock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, WAKELOCK_TAG); - mWakelock.acquire(); - Log.d(TAG, "Wakelock acquired"); - } else if (!enabled && mWakelock != null) { - mWakelock.release(); - mWakelock = null; - Log.d(TAG, "Wakelock released"); - } - } - - private boolean isRunning() { - return mThread != null && mThread.isAlive(); - } - - private void registerCallback(IMainCallback cb) { - if (cb != null) { - mCallbacks.register(cb); - sendMessage(MSG_SEND_STATUS, mStatus, 0, mError); - } - } - - private void unregisterCallback(IMainCallback cb) { - if (cb != null) { - mCallbacks.unregister(cb); - } - } - - /* - * start Main service without any callback - */ - public static void startService(Context context, boolean wakelock) { - Intent intent = new Intent(context, Main.class) - .putExtra("wakelock", wakelock); - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) - /* in Android 8+, we need to use this method - or else we'll get "IllegalStateException: - app is in background" */ - context.startForegroundService(intent); - else - context.startService(intent); - } - - public static void stopService(Context context) { - Intent intent = new Intent(context, Main.class); - context.stopService(intent); - } -} diff --git a/android/app/src/main/java/org/musicpd/Main.kt b/android/app/src/main/java/org/musicpd/Main.kt new file mode 100644 index 000000000..e2fcac4f5 --- /dev/null +++ b/android/app/src/main/java/org/musicpd/Main.kt @@ -0,0 +1,329 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// Copyright The Music Player Daemon Project +package org.musicpd + +import android.app.Notification +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.media.AudioManager +import android.os.Build +import android.os.IBinder +import android.os.Looper +import android.os.PowerManager +import android.os.PowerManager.WakeLock +import android.os.RemoteCallbackList +import android.os.RemoteException +import android.util.Log +import androidx.annotation.OptIn +import androidx.core.app.ServiceCompat +import androidx.media3.common.util.UnstableApi +import androidx.media3.session.MediaSession +import dagger.hilt.android.AndroidEntryPoint +import org.musicpd.Bridge.LogListener +import org.musicpd.data.LoggingRepository +import java.lang.reflect.Constructor +import javax.inject.Inject + +@AndroidEntryPoint +class Main : Service(), Runnable { + companion object { + private const val TAG = "Main" + private const val WAKELOCK_TAG = "mpd:wakelockmain" + + private const val MAIN_STATUS_ERROR = -1 + private const val MAIN_STATUS_STOPPED = 0 + private const val MAIN_STATUS_STARTED = 1 + + private const val MSG_SEND_STATUS = 0 + + const val SHUTDOWN_ACTION: String = "org.musicpd.action.ShutdownMPD" + + /* + * start Main service without any callback + */ + @JvmStatic + fun startService(context: Context, wakelock: Boolean) { + val intent = Intent(context, Main::class.java) + .putExtra("wakelock", wakelock) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) /* in Android 8+, we need to use this method + or else we'll get "IllegalStateException: + app is in background" */ + context.startForegroundService(intent) + else context.startService(intent) + } + } + + private var mThread: Thread? = null + private var mStatus = MAIN_STATUS_STOPPED + private var mAbort = false + private var mError: String? = null + private val mCallbacks = RemoteCallbackList<IMainCallback>() + private val mBinder: IBinder = MainStub(this) + private var mPauseOnHeadphonesDisconnect = false + private var mWakelock: WakeLock? = null + + private var mMediaSession: MediaSession? = null + + @JvmField + @Inject + var logging: LoggingRepository? = null + + internal class MainStub(private val mService: Main) : IMain.Stub() { + override fun start() { + mService.start() + } + + override fun stop() { + mService.stop() + } + + override fun setPauseOnHeadphonesDisconnect(enabled: Boolean) { + mService.setPauseOnHeadphonesDisconnect(enabled) + } + + override fun setWakelockEnabled(enabled: Boolean) { + mService.setWakelockEnabled(enabled) + } + + override fun isRunning(): Boolean { + return mService.isRunning + } + + override fun registerCallback(cb: IMainCallback) { + mService.registerCallback(cb) + } + + override fun unregisterCallback(cb: IMainCallback) { + mService.unregisterCallback(cb) + } + } + + @Synchronized + private fun sendMessage( + @Suppress("SameParameterValue") what: Int, + arg1: Int, + arg2: Int, + obj: Any? + ) { + var i = mCallbacks.beginBroadcast() + while (i > 0) { + i-- + val cb = mCallbacks.getBroadcastItem(i) + try { + when (what) { + MSG_SEND_STATUS -> when (arg1) { + MAIN_STATUS_ERROR -> cb.onError(obj as String?) + MAIN_STATUS_STOPPED -> cb.onStopped() + MAIN_STATUS_STARTED -> cb.onStarted() + } + } + } catch (ignored: RemoteException) { + } + } + mCallbacks.finishBroadcast() + } + + private val mLogListener = LogListener { priority, msg -> + logging?.addLogItem(priority, msg) + } + + override fun onBind(intent: Intent): IBinder { + return mBinder + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + if (intent?.action == SHUTDOWN_ACTION) { + stop() + } else { + start() + if (intent?.getBooleanExtra( + "wakelock", + false + ) == true + ) setWakelockEnabled(true) + } + return START_REDELIVER_INTENT + } + + override fun run() { + if (!Loader.loaded) { + val error = """ + Failed to load the native MPD library. + Report this problem to us, and include the following information: + SUPPORTED_ABIS=${java.lang.String.join(", ", *Build.SUPPORTED_ABIS)} + PRODUCT=${Build.PRODUCT} + FINGERPRINT=${Build.FINGERPRINT} + error=${Loader.error} + """.trimIndent() + setStatus(MAIN_STATUS_ERROR, error) + stopSelf() + return + } + synchronized(this) { + if (mAbort) return + setStatus(MAIN_STATUS_STARTED, null) + } + Bridge.run(this, mLogListener) + setStatus(MAIN_STATUS_STOPPED, null) + } + + @Synchronized + private fun setStatus(status: Int, error: String?) { + mStatus = status + mError = error + sendMessage(MSG_SEND_STATUS, mStatus, 0, mError) + } + + private fun createNotificationBuilderWithChannel(): Notification.Builder? { + val notificationManager = getSystemService(NOTIFICATION_SERVICE) as? NotificationManager + ?: return null + + val id = "org.musicpd" + val name = "MPD service" + val importance = 3 /* NotificationManager.IMPORTANCE_DEFAULT */ + + try { + val ncClass = Class.forName("android.app.NotificationChannel") + val ncCtor = ncClass.getConstructor( + String::class.java, + CharSequence::class.java, + Int::class.javaPrimitiveType + ) + val nc = ncCtor.newInstance(id, name, importance) + + val nmCreateNotificationChannelMethod = + NotificationManager::class.java.getMethod("createNotificationChannel", ncClass) + nmCreateNotificationChannelMethod.invoke(notificationManager, nc) + + val nbCtor: Constructor<*> = Notification.Builder::class.java.getConstructor( + Context::class.java, String::class.java + ) + return nbCtor.newInstance(this, id) as Notification.Builder + } catch (e: Exception) { + Log.e(TAG, "error creating the NotificationChannel", e) + return null + } + } + + @OptIn(markerClass = [UnstableApi::class]) + private fun start() { + if (mThread != null) return + + val filter = IntentFilter() + filter.addAction(AudioManager.ACTION_AUDIO_BECOMING_NOISY) + registerReceiver(object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + if (!mPauseOnHeadphonesDisconnect) return + if (intent.action === AudioManager.ACTION_AUDIO_BECOMING_NOISY) pause() + } + }, filter) + + val mainIntent = Intent(this, MainActivity::class.java) + mainIntent.setAction("android.intent.action.MAIN") + mainIntent.addCategory("android.intent.category.LAUNCHER") + val contentIntent = PendingIntent.getActivity( + this, 0, + mainIntent, PendingIntent.FLAG_CANCEL_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + val nBuilder: Notification.Builder? + if (Build.VERSION.SDK_INT >= 26 /* Build.VERSION_CODES.O */) { + nBuilder = createNotificationBuilderWithChannel() + if (nBuilder == null) return + } else nBuilder = Notification.Builder(this) + + val notification = + nBuilder.setContentTitle(getText(R.string.notification_title_mpd_running)) + .setContentText(getText(R.string.notification_text_mpd_running)) + .setSmallIcon(R.drawable.notification_icon) + .setContentIntent(contentIntent) + .build() + + mThread = Thread(this).apply { start() } + + val player = MPDPlayer(Looper.getMainLooper()) + mMediaSession = MediaSession.Builder(this, player).build() + + startForeground(R.string.notification_title_mpd_running, notification) + startService(Intent(this, Main::class.java)) + } + + private fun stop() { + mMediaSession?.let { + it.release() + mMediaSession = null + } + mThread?.let { thread -> + if (thread.isAlive) { + synchronized(this) { + if (mStatus == MAIN_STATUS_STARTED) Bridge.shutdown() + else mAbort = true + } + } + try { + thread.join() + mThread = null + mAbort = false + } catch (ie: InterruptedException) { + Log.e(TAG, "failed to join", ie) + } + } + setWakelockEnabled(false) + ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE) + stopSelf() + } + + private fun pause() { + if (mThread?.isAlive == true) { + synchronized(this) { + if (mStatus == MAIN_STATUS_STARTED) Bridge.pause() + } + } + } + + private fun setPauseOnHeadphonesDisconnect(enabled: Boolean) { + mPauseOnHeadphonesDisconnect = enabled + } + + private fun setWakelockEnabled(enabled: Boolean) { + if (enabled) { + val wakeLock = + mWakelock ?: run { + val pm = getSystemService(POWER_SERVICE) as PowerManager + pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, WAKELOCK_TAG).also { + mWakelock = it + } + } + wakeLock.acquire(10 * 60 * 1000L /*10 minutes*/) + Log.d(TAG, "Wakelock acquired") + } else { + mWakelock?.let { + it.release() + mWakelock = null + } + Log.d(TAG, "Wakelock released") + } + } + + private val isRunning: Boolean + get() = mThread?.isAlive == true + + private fun registerCallback(cb: IMainCallback?) { + if (cb != null) { + mCallbacks.register(cb) + sendMessage(MSG_SEND_STATUS, mStatus, 0, mError) + } + } + + private fun unregisterCallback(cb: IMainCallback?) { + if (cb != null) { + mCallbacks.unregister(cb) + } + } +}