diff --git a/android/.gitignore b/android/.gitignore index 94b83ee4aa..377b2bf76c 100644 --- a/android/.gitignore +++ b/android/.gitignore @@ -1,16 +1,24 @@ *.iml .gradle /local.properties -/.idea/caches -/.idea/libraries -/.idea/modules.xml -/.idea/workspace.xml -/.idea/navEditor.xml -/.idea/assetWizardSettings.xml +## ignoring .idea completely +# until a good reason emerges not to +/.idea +##-- +## moved the following into .idea/.gitignore +#/.idea/caches +#/.idea/libraries +#/.idea/modules.xml +#/.idea/workspace.xml +#/.idea/navEditor.xml +#/.idea/assetWizardSettings.xml +## -- .DS_Store /build /captures .externalNativeBuild .cxx local.properties +# both were used: different spelling app/src/main/jnilibs/ +app/src/main/jniLibs/ diff --git a/android/README.md b/android/README.md index edef6a1032..44d93b22ce 100644 --- a/android/README.md +++ b/android/README.md @@ -2,10 +2,16 @@ Notes and resources for MPD android maintainers. +## Build + +See [Compiling for Android](https://github.com/MusicPlayerDaemon/MPD/blob/45cb098cd765af12316f8dca5635ef10a852e013/doc/user.rst#compiling-for-android) + ## Android studio ### Version control +git ignoring .idea directory completely until a good reason emerges not to + * [How to manage projects under Version Control Systems (jetbrains.com)](https://intellij-support.jetbrains.com/hc/en-us/articles/206544839-How-to-manage-projects-under-Version-Control-Systems) * [gradle.xml should work like workspace.xml? (jetbrains.com)](https://youtrack.jetbrains.com/issue/IDEA-55923) @@ -14,4 +20,30 @@ Notes and resources for MPD android maintainers. * [Include prebuilt native libraries (developer.android.com)](https://developer.android.com/studio/projects/gradle-external-native-builds#jniLibs) +## Permissions + +### Files access + +The required permission depends on android SDK version: + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) + Manifest.permission.READ_MEDIA_AUDIO + else + Manifest.permission.READ_EXTERNAL_STORAGE + +### Permission request + +[Request runtime permissions](https://developer.android.com/training/permissions/requesting) + +Since Android 6.0 (API level 23): + +Android will ignore permission request and will not show the request dialog +if the user's action implies "don't ask again." +This leaves the app in a crippled state and the user confused. +Google says "don't try to convince the user", so it returns false for `shouldShowRequestPermissionRationale`. + +To help the user proceed, we show the `Request permission` button only if `shouldShowRequestPermissionRationale == true` +because there's a good chance the permission request dialog will not be ignored. +If `shouldShowRequestPermissionRationale == false` we instead show the "rationale" message and a button to open +the app info dialog where the user can explicitly grand the permission. \ No newline at end of file diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 4d28f2b257..bcf84c3ba7 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -1,8 +1,8 @@ plugins { - id("com.android.application") - id("org.jetbrains.kotlin.android") id("com.google.devtools.ksp") - id("com.google.dagger.hilt.android") + alias(libs.plugins.android.application) + alias(libs.plugins.jetbrains.kotlin.android) + alias(libs.plugins.dagger.hilt.android) } android { @@ -18,11 +18,6 @@ android { vectorDrawables { useSupportLibrary = true } - ndk { - // Specifies the ABI configurations of your native - // libraries Gradle should build and package with your app. - abiFilters += "arm64-v8a" - } } buildFeatures { @@ -31,7 +26,7 @@ android { } composeOptions { - kotlinCompilerExtensionVersion = "1.5.7" + kotlinCompilerExtensionVersion = "1.5.10" } buildTypes { @@ -46,12 +41,55 @@ android { ) } } + // flavors + flavorDimensions += "base" + productFlavors { + create("fail-test") { + // To test System.loadLibrary("mpd") failure + // exclude the native lib from the package + packaging { + jniLibs { + // it appears the 'excludes' is applied to all flavors + // even if it's only inside this flavor. + // this filters by task name to apply the exclusion only + // for this flavor name. + // (clearing the 'abiFilters' will only create a universal apk + // with all of the abi versions) + gradle.startParameter.getTaskNames().forEach { task -> + if (task.contains("fail-test", ignoreCase = true)) { + println("NOTICE: excluding libmpd.so from package $task for testing") + excludes += "**/libmpd.so" + } + } + } + } + } + create("arm64-v8a") { + ndk { + // ABI to include in package + //noinspection ChromeOsAbiSupport + abiFilters += listOf("arm64-v8a") + } + } + create("x86_64") { + ndk { + // ABI to include in package + abiFilters += listOf("x86_64") + } + } + create("universal") { + ndk { + // ABI to include in package + abiFilters += listOf("arm64-v8a", "x86_64") + } + } + } compileOptions { sourceCompatibility = JavaVersion.VERSION_1_9 targetCompatibility = JavaVersion.VERSION_1_9 } kotlinOptions { - jvmTarget = "9" + jvmTarget = JavaVersion.VERSION_1_9.toString() } packaging { resources { @@ -61,30 +99,30 @@ android { } dependencies { - implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7") - implementation(platform("androidx.compose:compose-bom:2025.01.01")) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(platform(libs.androidx.compose.bom)) - implementation("androidx.compose.material3:material3") - implementation("androidx.activity:activity-compose:1.10.0") - implementation("androidx.compose.material:material-icons-extended") - implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7") - implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7") - implementation("androidx.navigation:navigation-compose:2.8.6") + implementation(libs.androidx.material3) + implementation(libs.androidx.activity.compose) + implementation(libs.androidx.material.icons.extended) + implementation(libs.androidx.lifecycle.viewmodel.compose) + implementation(libs.androidx.lifecycle.runtime.compose) + implementation(libs.androidx.navigation.compose) - implementation("com.github.alorma:compose-settings-ui-m3:1.0.3") - implementation("com.github.alorma:compose-settings-storage-preferences:1.0.3") - implementation("com.google.accompanist:accompanist-permissions:0.33.2-alpha") + implementation(libs.compose.settings.ui.m3) + implementation(libs.compose.settings.storage.preferences) + implementation(libs.accompanist.permissions) - implementation("com.google.dagger:hilt-android:2.49") - ksp("com.google.dagger:dagger-compiler:2.49") - ksp("com.google.dagger:hilt-compiler:2.49") + implementation(libs.hilt.android) + ksp(libs.dagger.compiler) + ksp(libs.hilt.compiler) - implementation("androidx.media3:media3-session:1.5.1") + implementation(libs.androidx.media3.session) // Android Studio Preview support - implementation("androidx.compose.ui:ui-tooling-preview") - debugImplementation("androidx.compose.ui:ui-tooling") - debugImplementation("androidx.compose.ui:ui-test-manifest") + implementation(libs.androidx.ui.tooling.preview) + debugImplementation(libs.androidx.ui.tooling) + debugImplementation(libs.androidx.ui.test.manifest) - implementation("androidx.appcompat:appcompat:1.7.0") + implementation(libs.androidx.appcompat) } diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 400258e20b..7cdbb5ebdf 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -12,7 +12,9 @@ android:required="false" /> - + + diff --git a/android/app/src/main/java/org/musicpd/Loader.java b/android/app/src/main/java/org/musicpd/Loader.java deleted file mode 100644 index 21501e67b4..0000000000 --- a/android/app/src/main/java/org/musicpd/Loader.java +++ /dev/null @@ -1,23 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-or-later -// Copyright The Music Player Daemon Project - -package org.musicpd; - -import android.util.Log; - -public class Loader { - private static final String TAG = "MPD"; - - public static boolean loaded = false; - public static String error; - - static { - try { - System.loadLibrary("mpd"); - loaded = true; - } catch (UnsatisfiedLinkError e) { - Log.e(TAG, e.getMessage()); - error = e.getMessage(); - } - } -} diff --git a/android/app/src/main/java/org/musicpd/Loader.kt b/android/app/src/main/java/org/musicpd/Loader.kt new file mode 100644 index 0000000000..2d89d435ce --- /dev/null +++ b/android/app/src/main/java/org/musicpd/Loader.kt @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// Copyright The Music Player Daemon Project +package org.musicpd + +import android.content.Context +import android.os.Build +import android.util.Log + +object Loader { + private const val TAG = "Loader" + + private var loaded: Boolean = false + private var error: String? = null + private val failReason: String get() = error ?: "" + + val isLoaded: Boolean get() = loaded + + init { + load() + } + + private fun load() { + if (loaded) return + loaded = try { + error = null + System.loadLibrary("mpd") + Log.i(TAG, "mpd lib loaded") + true + } catch (e: Throwable) { + error = e.message ?: e.javaClass.simpleName + Log.e(TAG, "failed to load mpd lib: $failReason") + false + } + } + + fun loadFailureMessage(context: Context): String { + return context.getString( + R.string.mpd_load_failure_message, + Build.SUPPORTED_ABIS.joinToString(), + Build.PRODUCT, + Build.FINGERPRINT, + failReason + ) + } +} 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 5f440b964a..0000000000 --- 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 mCallbacks = new RemoteCallbackList(); - 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 0000000000..b7037feb99 --- /dev/null +++ b/android/app/src/main/java/org/musicpd/Main.kt @@ -0,0 +1,326 @@ +// 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 lateinit var mpdApp: MPDApplication + private lateinit var mpdLoader: Loader + + private var mThread: Thread? = null + private var mStatus = MAIN_STATUS_STOPPED + private var mAbort = false + private var mError: String? = null + private val mCallbacks = RemoteCallbackList() + 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) + } + } + + override fun onCreate() { + super.onCreate() + mpdLoader = Loader + } + + @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() { + 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() + + if (mpdLoader.isLoaded) { + 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) + } + } +} diff --git a/android/app/src/main/java/org/musicpd/ui/MainScreen.kt b/android/app/src/main/java/org/musicpd/ui/MainScreen.kt index d7dd975d48..5c538f208b 100644 --- a/android/app/src/main/java/org/musicpd/ui/MainScreen.kt +++ b/android/app/src/main/java/org/musicpd/ui/MainScreen.kt @@ -1,12 +1,9 @@ package org.musicpd.ui -import MPDSettings -import android.graphics.drawable.Icon import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Circle +import androidx.compose.material.icons.automirrored.filled.List import androidx.compose.material.icons.filled.Home -import androidx.compose.material.icons.filled.List import androidx.compose.material.icons.filled.Settings import androidx.compose.material3.Icon import androidx.compose.material3.NavigationBar @@ -40,7 +37,7 @@ sealed class NavigationItem(val route: String, val label: String, val icon: Imag data object Logs : NavigationItem( Screen.LOGS.name, "Logs", - Icons.Default.List) + Icons.AutoMirrored.Filled.List) data object Settings : NavigationItem( Screen.SETTINGS.name, "Settings", diff --git a/android/app/src/main/java/org/musicpd/ui/SettingsScreen.kt b/android/app/src/main/java/org/musicpd/ui/SettingsScreen.kt index 3748260daf..2ae0db92b8 100644 --- a/android/app/src/main/java/org/musicpd/ui/SettingsScreen.kt +++ b/android/app/src/main/java/org/musicpd/ui/SettingsScreen.kt @@ -1,3 +1,5 @@ +package org.musicpd.ui + import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material.icons.Icons @@ -14,7 +16,6 @@ import com.alorma.compose.settings.storage.preferences.rememberPreferenceBoolean import com.alorma.compose.settings.ui.SettingsSwitch import org.musicpd.Preferences import org.musicpd.R -import org.musicpd.ui.SettingsViewModel @Composable fun MPDSettings(settingsViewModel: SettingsViewModel) { diff --git a/android/app/src/main/java/org/musicpd/ui/SettingsViewModel.kt b/android/app/src/main/java/org/musicpd/ui/SettingsViewModel.kt index 62929ee4f0..c6f06ef3d2 100644 --- a/android/app/src/main/java/org/musicpd/ui/SettingsViewModel.kt +++ b/android/app/src/main/java/org/musicpd/ui/SettingsViewModel.kt @@ -6,6 +6,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import org.musicpd.Loader import org.musicpd.MainServiceClient import org.musicpd.Preferences import org.musicpd.data.LoggingRepository @@ -17,6 +18,7 @@ class SettingsViewModel @Inject constructor( private var loggingRepository: LoggingRepository ) : ViewModel() { private var mClient: MainServiceClient? = null + val mpdLoader = Loader data class StatusUiState( val statusMessage: String = "", diff --git a/android/app/src/main/java/org/musicpd/ui/StatusScreen.kt b/android/app/src/main/java/org/musicpd/ui/StatusScreen.kt index 8b24ec143a..cbd56a4dfa 100644 --- a/android/app/src/main/java/org/musicpd/ui/StatusScreen.kt +++ b/android/app/src/main/java/org/musicpd/ui/StatusScreen.kt @@ -1,12 +1,18 @@ package org.musicpd.ui import android.Manifest +import android.content.Context +import android.os.Build +import android.util.TypedValue +import androidx.annotation.AttrRes +import androidx.annotation.ColorInt import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Circle import androidx.compose.material3.Button @@ -19,64 +25,58 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.PermissionState import com.google.accompanist.permissions.isGranted import com.google.accompanist.permissions.rememberPermissionState import com.google.accompanist.permissions.shouldShowRationale import org.musicpd.R +import org.musicpd.utils.openAppSettings @OptIn(ExperimentalPermissionsApi::class) @Composable fun StatusScreen(settingsViewModel: SettingsViewModel) { - val storagePermissionState = rememberPermissionState( - Manifest.permission.READ_EXTERNAL_STORAGE - ) - - if (storagePermissionState.status.shouldShowRationale) { - Column( - Modifier - .padding(4.dp) - .fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center - ) { - Text(stringResource(id = R.string.external_files_permission_request)) - Button(onClick = { }) { - Text("Request permission") - } - } + val storagePermissionState = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + rememberPermissionState( + Manifest.permission.READ_MEDIA_AUDIO + ) } else { - Column( - Modifier - .padding(4.dp) - .fillMaxSize(), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center - ) { - NetworkAddress() - ServerStatus(settingsViewModel) - if (!storagePermissionState.status.isGranted) { - OutlinedButton( - onClick = { storagePermissionState.launchPermissionRequest() }, Modifier - .padding(4.dp) - .fillMaxWidth() - ) { - Text( - "Request external storage permission", - color = MaterialTheme.colorScheme.secondary - ) - } - } - } + rememberPermissionState( + Manifest.permission.READ_EXTERNAL_STORAGE + ) + } + + Column( + Modifier + .padding(4.dp) + .fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + NetworkAddress() + ServerStatus(settingsViewModel, storagePermissionState) + AudioMediaPermission(storagePermissionState) + MPDLoaderStatus(settingsViewModel) + } +} + +@ColorInt +fun getThemeColorAttribute(context: Context, @AttrRes attr: Int): Int { + val value = TypedValue() + if (context.theme.resolveAttribute(attr, value, true)) { + return value.data } + return android.graphics.Color.BLACK } +@OptIn(ExperimentalPermissionsApi::class) @Composable -fun ServerStatus(settingsViewModel: SettingsViewModel) { +fun ServerStatus(settingsViewModel: SettingsViewModel, storagePermissionState: PermissionState) { val context = LocalContext.current val statusUiState by settingsViewModel.statusUIState.collectAsState() @@ -89,21 +89,35 @@ fun ServerStatus(settingsViewModel: SettingsViewModel) { verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceEvenly ) { - Row { + Row(verticalAlignment = Alignment.CenterVertically) { Icon( imageVector = Icons.Default.Circle, contentDescription = "", - tint = if (statusUiState.running) Color(0xFFB8F397) else Color(0xFFFFDAD6) + tint = Color( + getThemeColorAttribute( + context, + if (statusUiState.running) R.attr.appColorPositive else R.attr.appColorNegative + ) + ), + modifier = Modifier + .padding(end = 8.dp) + .alpha(0.6f) ) - Text(text = if (statusUiState.running) "Running" else "Stopped") + Text(text = stringResource(id = if (statusUiState.running) R.string.running else R.string.stopped)) } - Button(onClick = { - if (statusUiState.running) - settingsViewModel.stopMPD() - else - settingsViewModel.startMPD(context) - }) { - Text(text = if (statusUiState.running) "Stop MPD" else "Start MPD") + Button( + onClick = { + if (statusUiState.running) + settingsViewModel.stopMPD() + else + settingsViewModel.startMPD(context) + }, + enabled = settingsViewModel.mpdLoader.isLoaded + && storagePermissionState.status.isGranted + ) { + Text( + text = stringResource(id = if (statusUiState.running) R.string.stopMPD else R.string.startMPD) + ) } } Row( @@ -116,4 +130,59 @@ fun ServerStatus(settingsViewModel: SettingsViewModel) { Text(text = statusUiState.statusMessage) } } +} + +@OptIn(ExperimentalPermissionsApi::class) +@Composable +fun AudioMediaPermission(storagePermissionState: PermissionState) { + val permissionStatus = storagePermissionState.status + if (!permissionStatus.isGranted) { + val context = LocalContext.current + Column( + Modifier + .padding(4.dp) + .fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + stringResource(id = R.string.external_files_permission_request), + Modifier.padding(16.dp) + ) + if (storagePermissionState.status.shouldShowRationale) { + Button(onClick = { + storagePermissionState.launchPermissionRequest() + }) { + Text("Request permission") + } + } else { + OutlinedButton( + onClick = { + openAppSettings(context, context.packageName) + }, + Modifier.padding(16.dp) + ) { + Text( + stringResource(id = R.string.title_open_app_info), + color = MaterialTheme.colorScheme.secondary + ) + } + } + } + } +} + +@Composable +fun MPDLoaderStatus(settingsViewModel: SettingsViewModel) { + val loader = settingsViewModel.mpdLoader + if (!loader.isLoaded) { + val context = LocalContext.current + SelectionContainer { + Text( + loader.loadFailureMessage(context), + Modifier.padding(16.dp), + color = MaterialTheme.colorScheme.error + ) + } + } } \ No newline at end of file diff --git a/android/app/src/main/java/org/musicpd/utils/IntentUtils.kt b/android/app/src/main/java/org/musicpd/utils/IntentUtils.kt new file mode 100644 index 0000000000..b9a75128ac --- /dev/null +++ b/android/app/src/main/java/org/musicpd/utils/IntentUtils.kt @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// Copyright The Music Player Daemon Project +package org.musicpd.utils + +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.provider.Settings +import android.util.Log + +private const val TAG = "IntentUtils" + +fun openAppSettings( + context: Context, + packageName: String +) { + try { + context.startActivity(Intent().apply { + setAction(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) + setData(Uri.parse("package:$packageName")) + addCategory(Intent.CATEGORY_DEFAULT) + addFlags(Intent.FLAG_ACTIVITY_NO_HISTORY) + addFlags(Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS) + }) + } catch (e: ActivityNotFoundException) { + Log.e( + TAG, + "failed to open app settings for package: $packageName", e + ) + } +} diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 92f8139b2c..6c47fa042f 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -1,13 +1,25 @@ - MPD - Music Player Daemon is running - Touch for MPD options. - MPD is running - MPD is not running - Run MPD automatically on boot - Prevent suspend when MPD is running (Wakelock) - Pause MPD when headphones disconnect - MPD requires access to external files to play local music. Please grant the permission. + MPD + Music Player Daemon is running + Touch for MPD options. + MPD is running + MPD is not running + Run MPD automatically on boot + Prevent suspend when MPD is running (Wakelock) + Pause MPD when headphones disconnect + MPD requires access to external files to play local music. Please grant the permission. + Open app info + "Failed to load the native MPD library. +Report this problem to us, and include the following information: +SUPPORTED_ABIS=%1$s +PRODUCT=%2$s +FINGERPRINT=%3$s +error=%4$s" + + Stopped + Running + Stop MPD + Start MPD diff --git a/android/app/src/main/res/values/themes.xml b/android/app/src/main/res/values/themes.xml index c9502c8f8b..d53aa8c457 100644 --- a/android/app/src/main/res/values/themes.xml +++ b/android/app/src/main/res/values/themes.xml @@ -1,4 +1,21 @@ - \ No newline at end of file diff --git a/android/build.gradle.kts b/android/build.gradle.kts index 825708fd07..4452473c1f 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -1,7 +1,7 @@ // Top-level build file where you can add configuration options common to all sub-projects/modules. plugins { - id("com.android.application") version "8.5.2" apply false - id("org.jetbrains.kotlin.android") version "1.9.21" apply false id("com.google.devtools.ksp") version "1.9.22-1.0.16" apply false - id("com.google.dagger.hilt.android") version "2.49" apply false + alias(libs.plugins.android.application) apply false + alias(libs.plugins.jetbrains.kotlin.android) apply false + alias(libs.plugins.dagger.hilt.android) apply false } \ No newline at end of file diff --git a/android/build.py b/android/build.py index 69ac747305..c8d950795a 100755 --- a/android/build.py +++ b/android/build.py @@ -70,3 +70,13 @@ subprocess.check_call([ninja], env=toolchain.env) subprocess.check_call([ninja, 'install'], env=toolchain.env) + +print(""" +------------------------------------- +## To build the android app: +# cd ../../android +# ./gradlew assemble{}Debug +## or, for a universal apk (includes both arm64-v8a and x86_64) +# ./gradlew assembleUniversalDebug +------------------------------------- +""".format(android_abi.capitalize())) \ No newline at end of file diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml new file mode 100644 index 0000000000..0800b2175d --- /dev/null +++ b/android/gradle/libs.versions.toml @@ -0,0 +1,41 @@ +[versions] +androidGradlePlugin = "8.5.2" +accompanistPermissions = "0.33.2-alpha" +activityCompose = "1.10.0" +appcompat = "1.7.0" +composeBom = "2025.01.01" +composeSettingsStoragePreferences = "1.0.3" +composeSettingsUiM3 = "1.0.3" +daggerCompiler = "2.49" +hiltAndroid = "2.49" +hiltCompiler = "2.49" +lifecycleRuntimeKtx = "2.8.7" +media3Session = "1.5.1" +navigationCompose = "2.8.6" +kotlin = "1.9.22" + +[libraries] +accompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanistPermissions" } +androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activityCompose" } +androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompat" } +androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref = "composeBom" } +androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycleRuntimeKtx" } +androidx-lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" } +androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycleRuntimeKtx" } +androidx-material-icons-extended = { module = "androidx.compose.material:material-icons-extended" } +androidx-material3 = { module = "androidx.compose.material3:material3" } +androidx-media3-session = { module = "androidx.media3:media3-session", version.ref = "media3Session" } +androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigationCompose" } +androidx-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" } +androidx-ui-tooling = { module = "androidx.compose.ui:ui-tooling" } +androidx-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" } +compose-settings-storage-preferences = { module = "com.github.alorma:compose-settings-storage-preferences", version.ref = "composeSettingsStoragePreferences" } +compose-settings-ui-m3 = { module = "com.github.alorma:compose-settings-ui-m3", version.ref = "composeSettingsUiM3" } +dagger-compiler = { module = "com.google.dagger:dagger-compiler", version.ref = "daggerCompiler" } +hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hiltAndroid" } +hilt-compiler = { module = "com.google.dagger:hilt-compiler", version.ref = "hiltCompiler" } + +[plugins] +android-application = { id = "com.android.application", version.ref = "androidGradlePlugin" } +jetbrains-kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } +dagger-hilt-android = { id = "com.google.dagger.hilt.android", version.ref = "hiltAndroid" } \ No newline at end of file diff --git a/doc/user.rst b/doc/user.rst index b0a4de521e..ee77c0008f 100644 --- a/doc/user.rst +++ b/doc/user.rst @@ -223,11 +223,16 @@ tarball and change into the directory. Then, instead of -Dwrap_mode=forcefallback \ -Dandroid_debug_keystore=$HOME/.android/debug.keystore cd ../../android - ./gradlew assembleDebug + ./gradlew assemble{ABI}Debug + +In the argument to `gradlew`, replace `{ABI}` with the build ABI or `Universal`. +The `productFlavor` names defined in `build.android.kts` match the ABI. +A universal apk (includes both arm64-v8a and x86_64) + :envvar:`SDK_PATH` is the absolute path where you installed the Android SDK; :envvar:`NDK_PATH` is the Android NDK installation path; -ABI is the Android ABI to be built, e.g. ":code:`arm64-v8a`". +ABI is the Android ABI to be built, e.g. ":code:`x86`, `x86_64`, `armeabi`, `armeabi-v7a`, `arm64-v8a`". This downloads various library sources, and then configures and builds :program:`MPD`.