From 7404be41dc524524d7890c15800fe92fbdd390db Mon Sep 17 00:00:00 2001 From: gd Date: Mon, 3 Feb 2025 21:14:28 +0200 Subject: [PATCH 01/14] android: git ignoring .idea directory completely until a good reason emerges not to --- android/.gitignore | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/android/.gitignore b/android/.gitignore index 94b83ee4aa..0f682824e2 100644 --- a/android/.gitignore +++ b/android/.gitignore @@ -1,12 +1,18 @@ *.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 From 491e726540b1846bca405ab617d0dc099d767c80 Mon Sep 17 00:00:00 2001 From: gd Date: Mon, 3 Feb 2025 21:42:51 +0200 Subject: [PATCH 02/14] android: manifest - added sdk version to legacy permission READ_EXTERNAL_STORAGE, added permission READ_MEDIA_AUDIO Change fo r SDK version 33 and above --- android/app/src/main/AndroidManifest.xml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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" /> - + + From cb62aff43ec2d427a7e7a83795f58c500e5cd3ae Mon Sep 17 00:00:00 2001 From: gd Date: Mon, 3 Feb 2025 21:45:45 +0200 Subject: [PATCH 03/14] android: added missing package declaration in SettingsScreen.kt --- android/app/src/main/java/org/musicpd/ui/MainScreen.kt | 3 --- android/app/src/main/java/org/musicpd/ui/SettingsScreen.kt | 3 ++- 2 files changed, 2 insertions(+), 4 deletions(-) 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..abcf082389 100644 --- a/android/app/src/main/java/org/musicpd/ui/MainScreen.kt +++ b/android/app/src/main/java/org/musicpd/ui/MainScreen.kt @@ -1,10 +1,7 @@ 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.filled.Home import androidx.compose.material.icons.filled.List import androidx.compose.material.icons.filled.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) { From 51242be72bd655b9df3d171256367030992f4d31 Mon Sep 17 00:00:00 2001 From: gd Date: Mon, 3 Feb 2025 22:01:16 +0200 Subject: [PATCH 04/14] android: changed permissions handling UI in status screen when show rationale is false 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 premission 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. --- android/README.md | 30 ++++++ .../main/java/org/musicpd/ui/StatusScreen.kt | 101 +++++++++++------- .../java/org/musicpd/utils/IntentUtils.kt | 30 ++++++ android/app/src/main/res/values/strings.xml | 1 + 4 files changed, 123 insertions(+), 39 deletions(-) create mode 100644 android/app/src/main/java/org/musicpd/utils/IntentUtils.kt diff --git a/android/README.md b/android/README.md index edef6a1032..371a0a1b68 100644 --- a/android/README.md +++ b/android/README.md @@ -6,6 +6,9 @@ Notes and resources for MPD android maintainers. ### 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) @@ -15,3 +18,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/src/main/java/org/musicpd/ui/StatusScreen.kt b/android/app/src/main/java/org/musicpd/ui/StatusScreen.kt index 8b24ec143a..b913c57b58 100644 --- a/android/app/src/main/java/org/musicpd/ui/StatusScreen.kt +++ b/android/app/src/main/java/org/musicpd/ui/StatusScreen.kt @@ -1,6 +1,7 @@ package org.musicpd.ui import android.Manifest +import android.os.Build import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -24,54 +25,36 @@ 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) + AudioMediaPermission(storagePermissionState) } } @@ -116,4 +99,44 @@ 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 + ) + } + } + } + } } \ 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..806ef51a4e --- /dev/null +++ b/android/app/src/main/java/org/musicpd/utils/IntentUtils.kt @@ -0,0 +1,30 @@ +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..32877eb033 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -10,4 +10,5 @@ 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 From fe42ad2439ab948c2a14e47e07f58f9255e81042 Mon Sep 17 00:00:00 2001 From: gd Date: Mon, 3 Feb 2025 22:29:09 +0200 Subject: [PATCH 05/14] android: .gitignore - added previously misspelled app/src/main/jniLibs/ --- android/.gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/android/.gitignore b/android/.gitignore index 0f682824e2..377b2bf76c 100644 --- a/android/.gitignore +++ b/android/.gitignore @@ -19,4 +19,6 @@ .externalNativeBuild .cxx local.properties +# both were used: different spelling app/src/main/jnilibs/ +app/src/main/jniLibs/ From 8a642c8a8304faaa96840f835a23a1af12224fda Mon Sep 17 00:00:00 2001 From: gd Date: Mon, 3 Feb 2025 22:32:32 +0200 Subject: [PATCH 06/14] android: MainScreen - use Icons.AutoMirrored.Filled.List instead of deprecated Icons.Default.List --- android/app/src/main/java/org/musicpd/ui/MainScreen.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 abcf082389..5c538f208b 100644 --- a/android/app/src/main/java/org/musicpd/ui/MainScreen.kt +++ b/android/app/src/main/java/org/musicpd/ui/MainScreen.kt @@ -2,8 +2,8 @@ package org.musicpd.ui import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons +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 @@ -37,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", From 834d6dcf46af12bb1da19dced6a52c693f634e21 Mon Sep 17 00:00:00 2001 From: gd Date: Mon, 3 Feb 2025 23:07:42 +0200 Subject: [PATCH 07/14] android: build version of kotlin and compose updated. Changed to compatible versions according to https://developer.android.com/jetpack/androidx/releases/compose-kotlin#kts --- android/app/build.gradle.kts | 2 +- android/build.gradle.kts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 4d28f2b257..531795391c 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -31,7 +31,7 @@ android { } composeOptions { - kotlinCompilerExtensionVersion = "1.5.7" + kotlinCompilerExtensionVersion = "1.5.10" } buildTypes { diff --git a/android/build.gradle.kts b/android/build.gradle.kts index 825708fd07..b14a8118ab 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("org.jetbrains.kotlin.android") version "1.9.22" 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 } \ No newline at end of file From 034bcf4f442a7b30278b171e4e884725d30bcaa1 Mon Sep 17 00:00:00 2001 From: gd Date: Tue, 4 Feb 2025 10:22:17 +0200 Subject: [PATCH 08/14] android: added product flavors to separatly build apk for arm64-v8a or x86_64 --- android/README.md | 12 +++++++----- android/app/build.gradle.kts | 24 ++++++++++++++++++------ android/build.py | 8 ++++++++ doc/user.rst | 7 +++++-- 4 files changed, 38 insertions(+), 13 deletions(-) diff --git a/android/README.md b/android/README.md index 371a0a1b68..44d93b22ce 100644 --- a/android/README.md +++ b/android/README.md @@ -2,11 +2,14 @@ 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) @@ -17,10 +20,9 @@ git ignoring .idea directory completely until a good reason emerges not to * [Include prebuilt native libraries (developer.android.com)](https://developer.android.com/studio/projects/gradle-external-native-builds#jniLibs) +## Permissions -### Permissions - -#### Files access +### Files access The required permission depends on android SDK version: @@ -29,7 +31,7 @@ The required permission depends on android SDK version: else Manifest.permission.READ_EXTERNAL_STORAGE -#### Permission request +### Permission request [Request runtime permissions](https://developer.android.com/training/permissions/requesting) diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 531795391c..2f2dbb96e3 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -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 { @@ -46,12 +41,29 @@ android { ) } } + // flavors + flavorDimensions += "base" + productFlavors { + 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") + } + } + } compileOptions { sourceCompatibility = JavaVersion.VERSION_1_9 targetCompatibility = JavaVersion.VERSION_1_9 } kotlinOptions { - jvmTarget = "9" + jvmTarget = JavaVersion.VERSION_1_9.toString() } packaging { resources { diff --git a/android/build.py b/android/build.py index 69ac747305..5a74c4009b 100755 --- a/android/build.py +++ b/android/build.py @@ -70,3 +70,11 @@ 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 +------------------------------------- +""".format(android_abi.capitalize())) \ No newline at end of file diff --git a/doc/user.rst b/doc/user.rst index b0a4de521e..e76d8e8a53 100644 --- a/doc/user.rst +++ b/doc/user.rst @@ -223,11 +223,14 @@ 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. +The `productFlavor` names defined in `build.android.kts` match the ABI. :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`. From 0bf77f4eb34afcff2de83c98d869f94de5e416f0 Mon Sep 17 00:00:00 2001 From: gd Date: Wed, 5 Feb 2025 11:20:39 +0200 Subject: [PATCH 09/14] android: converted Main from java to kotlin --- .../app/src/main/java/org/musicpd/Main.java | 338 ------------------ android/app/src/main/java/org/musicpd/Main.kt | 329 +++++++++++++++++ 2 files changed, 329 insertions(+), 338 deletions(-) delete mode 100644 android/app/src/main/java/org/musicpd/Main.java create mode 100644 android/app/src/main/java/org/musicpd/Main.kt 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..e2fcac4f5f --- /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() + 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) + } + } +} From 2bf9fdf10ed59b70325d772da7ee3325b99a5e56 Mon Sep 17 00:00:00 2001 From: gd Date: Wed, 5 Feb 2025 20:15:58 +0200 Subject: [PATCH 10/14] android: migrated build to version catalogs For easier management of dependencies versions: https://developer.android.com/build/migrate-to-catalogs --- android/app/build.gradle.kts | 44 +++++++++++++++---------------- android/build.gradle.kts | 6 ++--- android/gradle/libs.versions.toml | 41 ++++++++++++++++++++++++++++ 3 files changed, 66 insertions(+), 25 deletions(-) create mode 100644 android/gradle/libs.versions.toml diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 2f2dbb96e3..4c0a543328 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 { @@ -73,30 +73,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/build.gradle.kts b/android/build.gradle.kts index b14a8118ab..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.22" 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/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 From 038759506f497ab3b8ad34f4f5bb2d37a63fd173 Mon Sep 17 00:00:00 2001 From: gd Date: Wed, 5 Feb 2025 20:31:43 +0200 Subject: [PATCH 11/14] android: added 'universal' flavor that includes both both arm64-v8a and x86_64 versions of libmpd.so --- android/app/build.gradle.kts | 6 ++++++ android/build.py | 2 ++ doc/user.rst | 4 +++- 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 4c0a543328..0a2618facc 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -57,6 +57,12 @@ android { abiFilters += listOf("x86_64") } } + create("universal") { + ndk { + // ABI to include in package + abiFilters += listOf("arm64-v8a", "x86_64") + } + } } compileOptions { sourceCompatibility = JavaVersion.VERSION_1_9 diff --git a/android/build.py b/android/build.py index 5a74c4009b..c8d950795a 100755 --- a/android/build.py +++ b/android/build.py @@ -76,5 +76,7 @@ ## 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/doc/user.rst b/doc/user.rst index e76d8e8a53..ee77c0008f 100644 --- a/doc/user.rst +++ b/doc/user.rst @@ -225,8 +225,10 @@ tarball and change into the directory. Then, instead of cd ../../android ./gradlew assemble{ABI}Debug -In the argument to `gradlew`, replace `{ABI}` with the build ABI. +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; From 9eb58795423a5df2d8e3e512efe3af02bc2363c0 Mon Sep 17 00:00:00 2001 From: gd Date: Thu, 6 Feb 2025 12:09:56 +0200 Subject: [PATCH 12/14] android: IntentUtils - added license comment --- android/app/src/main/java/org/musicpd/utils/IntentUtils.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/android/app/src/main/java/org/musicpd/utils/IntentUtils.kt b/android/app/src/main/java/org/musicpd/utils/IntentUtils.kt index 806ef51a4e..b9a75128ac 100644 --- a/android/app/src/main/java/org/musicpd/utils/IntentUtils.kt +++ b/android/app/src/main/java/org/musicpd/utils/IntentUtils.kt @@ -1,3 +1,5 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// Copyright The Music Player Daemon Project package org.musicpd.utils import android.content.ActivityNotFoundException From ae1c5e3424ece9884206acc94663e4a4b8f368ee Mon Sep 17 00:00:00 2001 From: gd Date: Thu, 6 Feb 2025 14:11:06 +0200 Subject: [PATCH 13/14] android: build.gradle - added build flavor "fail-test" to test System.loadLibrary("mpd") failure --- android/app/build.gradle.kts | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 0a2618facc..bcf84c3ba7 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -44,6 +44,26 @@ 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 From f1e43cb49866ac6a1817ad4c5b09973cbe8e2ce6 Mon Sep 17 00:00:00 2001 From: gd Date: Thu, 6 Feb 2025 14:23:26 +0200 Subject: [PATCH 14/14] android: Loader - load early (before service thread) both in activity and service. Loader converted from java to kotlin. Instead of loading libmpd when the service thread is started, the service will not start the the thread if libmpd failed to load. The loader is also accessed by the view data to let the ui adjust if failed to load, by showing the failure reason and disabling the Start MPD button. --- .../app/src/main/java/org/musicpd/Loader.java | 23 ------ .../app/src/main/java/org/musicpd/Loader.kt | 45 ++++++++++++ android/app/src/main/java/org/musicpd/Main.kt | 25 +++---- .../java/org/musicpd/ui/SettingsViewModel.kt | 2 + .../main/java/org/musicpd/ui/StatusScreen.kt | 70 +++++++++++++++---- android/app/src/main/res/values/strings.xml | 31 +++++--- android/app/src/main/res/values/themes.xml | 19 ++++- 7 files changed, 155 insertions(+), 60 deletions(-) delete mode 100644 android/app/src/main/java/org/musicpd/Loader.java create mode 100644 android/app/src/main/java/org/musicpd/Loader.kt 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.kt b/android/app/src/main/java/org/musicpd/Main.kt index e2fcac4f5f..b7037feb99 100644 --- a/android/app/src/main/java/org/musicpd/Main.kt +++ b/android/app/src/main/java/org/musicpd/Main.kt @@ -59,6 +59,9 @@ class Main : Service(), Runnable { } } + 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 @@ -104,6 +107,11 @@ class Main : Service(), Runnable { } } + override fun onCreate() { + super.onCreate() + mpdLoader = Loader + } + @Synchronized private fun sendMessage( @Suppress("SameParameterValue") what: Int, @@ -152,19 +160,6 @@ class Main : Service(), Runnable { } 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) @@ -245,7 +240,9 @@ class Main : Service(), Runnable { .setContentIntent(contentIntent) .build() - mThread = Thread(this).apply { start() } + if (mpdLoader.isLoaded) { + mThread = Thread(this).apply { start() } + } val player = MPDPlayer(Looper.getMainLooper()) mMediaSession = MediaSession.Builder(this, player).build() 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 b913c57b58..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,13 +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 @@ -20,6 +25,7 @@ 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 @@ -53,13 +59,24 @@ fun StatusScreen(settingsViewModel: SettingsViewModel) { verticalArrangement = Arrangement.Center ) { NetworkAddress() - ServerStatus(settingsViewModel) + 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() @@ -72,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( @@ -139,4 +170,19 @@ fun AudioMediaPermission(storagePermissionState: PermissionState) { } } } +} + +@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/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 32877eb033..6c47fa042f 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -1,14 +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. - Open app info + 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