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 @@
-
+ #F44336
+ #B71C1C
+ #81C784
+ #388E3C
+
+ @color/red_900
+ @color/red_500
+
+ @color/green_700
+ @color/green_300
+
+
+
+
+
\ 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`.