diff options
author | Thomas Guillem <thomas@gllm.fr> | 2014-09-16 12:00:59 +0200 |
---|---|---|
committer | Max Kellermann <max@musicpd.org> | 2018-08-19 23:35:49 +0200 |
commit | 54a5491b86ce248fc56739aefe555d20e65d56a8 (patch) | |
tree | 045c834f2478300445d3bfcacb2811a53f55d193 | |
parent | aff070bcbb771a5096f02b60c84aacfde601e043 (diff) |
android: Main is now a service
- add Settings: Activity to start / stop MPD Service (Main).
- Main is a service that run in foreground with a notification. See
Service.startForeground documentation for more details.
- Main.Client is used to control the service: start or stop it and also receive
callbacks when service encounters an error, is killed, is started or is
stopped.
- Main.start to start the service without any fallback.
-rw-r--r-- | Makefile.am | 15 | ||||
-rw-r--r-- | NEWS | 3 | ||||
-rw-r--r-- | android/AndroidManifest.xml | 6 | ||||
-rw-r--r-- | android/res/layout/custom_notification_gb.xml | 22 | ||||
-rw-r--r-- | android/res/values/strings.xml | 2 | ||||
-rw-r--r-- | android/src/IMain.aidl | 12 | ||||
-rw-r--r-- | android/src/IMainCallback.aidl | 9 | ||||
-rw-r--r-- | android/src/Main.java | 417 | ||||
-rw-r--r-- | android/src/Settings.java | 145 |
9 files changed, 592 insertions, 39 deletions
diff --git a/Makefile.am b/Makefile.am index 495157e78..fc0670220 100644 --- a/Makefile.am +++ b/Makefile.am @@ -301,6 +301,7 @@ ANDROID_BUILD_TOOLS_DIR = $(ANDROID_SDK)/build-tools/$(ANDROID_SDK_BUILD_TOOLS_V ANDROID_SDK_PLATFORM_DIR = $(ANDROID_SDK)/platforms/$(ANDROID_SDK_PLATFORM) JAVAC = javac +AIDL = $(ANDROID_BUILD_TOOLS_DIR)/aidl AAPT = $(ANDROID_BUILD_TOOLS_DIR)/aapt DX = $(ANDROID_BUILD_TOOLS_DIR)/dx ZIPALIGN = $(ANDROID_BUILD_TOOLS_DIR)/zipalign @@ -308,11 +309,21 @@ ZIPALIGN = $(ANDROID_BUILD_TOOLS_DIR)/zipalign ANDROID_XML_RES := $(wildcard $(srcdir)/android/res/*/*.xml) ANDROID_XML_RES_COPIES := $(patsubst $(srcdir)/android/%,android/build/%,$(ANDROID_XML_RES)) -JAVA_SOURCE_NAMES = Bridge.java Loader.java Main.java +JAVA_SOURCE_NAMES = Bridge.java Loader.java Main.java Settings.java JAVA_SOURCE_PATHS = $(addprefix $(srcdir)/android/src/,$(JAVA_SOURCE_NAMES)) JAVA_CLASSFILES_DIR = android/build/classes +AIDL_FILES = $(wildcard $(srcdir)/android/src/*.aidl) +AIDL_JAVA_FILES = $(patsubst $(srcdir)/android/src/%.aidl,android/build/src/org/musicpd/%.java,$(AIDL_FILES)) + +android/build/src/org/musicpd/IMain.java: android/build/src/org/musicpd/IMainCallback.java + +$(AIDL_JAVA_FILES): android/build/src/org/musicpd/%.java: $(srcdir)/android/src/%.aidl + @$(MKDIR_P) $(@D) + @cp $< $(@D)/ + $(AIDL) -Iandroid/build/src -oandroid/build/src $(patsubst %.java,%.aidl,$@) + $(ANDROID_XML_RES_COPIES): $(ANDROID_XML_RES) @$(MKDIR_P) $(dir $@) cp $(patsubst android/build/%,$(srcdir)/android/%,$@) $@ @@ -330,7 +341,7 @@ android/build/resources.apk: $(ANDROID_XML_RES_COPIES) android/build/res/drawabl # R.java is generated by aapt, when resources.apk is generated android/build/gen/org/musicpd/R.java: android/build/resources.apk -android/build/classes.dex: $(JAVA_SOURCE_PATHS) android/build/gen/org/musicpd/R.java +android/build/classes.dex: $(JAVA_SOURCE_PATHS) $(AIDL_JAVA_FILES) android/build/gen/org/musicpd/R.java @$(MKDIR_P) $(JAVA_CLASSFILES_DIR) $(JAVAC) -source 1.6 -target 1.6 -Xlint:-options \ -cp $(ANDROID_SDK_PLATFORM_DIR)/android.jar:$(JAVA_CLASSFILES_DIR) \ @@ -1,6 +1,9 @@ ver 0.20.22 (not yet released) * storage - curl: URL-encode paths +* Android + - now runs as a service + - add button to start/stop MPD ver 0.20.21 (2018/08/17) * database diff --git a/android/AndroidManifest.xml b/android/AndroidManifest.xml index dba4f518f..6095c9460 100644 --- a/android/AndroidManifest.xml +++ b/android/AndroidManifest.xml @@ -8,14 +8,14 @@ <uses-sdk android:minSdkVersion="21" android:targetSdkVersion="26"/> <application android:icon="@drawable/icon" android:label="@string/app_name"> - <activity android:name=".Main" - android:label="@string/app_name" - android:launchMode="singleInstance"> + <activity android:name=".Settings" + android:label="@string/app_name"> <intent-filter> <action android:name="android.intent.action.MAIN" /> <category android:name="android.intent.category.LAUNCHER" /> </intent-filter> </activity> + <service android:name=".Main" /> </application> <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/> diff --git a/android/res/layout/custom_notification_gb.xml b/android/res/layout/custom_notification_gb.xml new file mode 100644 index 000000000..92a6036e2 --- /dev/null +++ b/android/res/layout/custom_notification_gb.xml @@ -0,0 +1,22 @@ +<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android" + android:id="@+id/layout" + android:layout_width="fill_parent" + android:layout_height="fill_parent" + android:padding="10dp" > + <ImageView android:id="@+id/image" + android:layout_width="wrap_content" + android:layout_height="fill_parent" + android:layout_alignParentLeft="true" + android:layout_marginRight="10dp" /> + <TextView android:id="@+id/title" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_toRightOf="@id/image" + style="Custom Notification Title" /> + <TextView android:id="@+id/text" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_toRightOf="@id/image" + android:layout_below="@id/title" + style="Custom Notification Text" /> +</RelativeLayout> diff --git a/android/res/values/strings.xml b/android/res/values/strings.xml index 416c8de9f..bcc1ae0c5 100644 --- a/android/res/values/strings.xml +++ b/android/res/values/strings.xml @@ -2,4 +2,6 @@ <resources> <string name="app_name">MPD</string> + <string name="notification_title_mpd_running">Music Player Daemon is running</string> + <string name="notification_text_mpd_running">Touch for MPD options.</string> </resources> diff --git a/android/src/IMain.aidl b/android/src/IMain.aidl new file mode 100644 index 000000000..ba7050d79 --- /dev/null +++ b/android/src/IMain.aidl @@ -0,0 +1,12 @@ +package org.musicpd; +import org.musicpd.IMainCallback; + +interface IMain +{ + void start(); + void stop(); + void setWakelockEnabled(boolean enabled); + boolean isRunning(); + void registerCallback(IMainCallback cb); + void unregisterCallback(IMainCallback cb); +} diff --git a/android/src/IMainCallback.aidl b/android/src/IMainCallback.aidl new file mode 100644 index 000000000..c8cdaa4a0 --- /dev/null +++ b/android/src/IMainCallback.aidl @@ -0,0 +1,9 @@ +package org.musicpd; + +interface IMainCallback +{ + void onStarted(); + void onStopped(); + void onError(String error); + void onLog(int priority, String msg); +} diff --git a/android/src/Main.java b/android/src/Main.java index 816b62cdb..44e2e3d54 100644 --- a/android/src/Main.java +++ b/android/src/Main.java @@ -19,57 +19,406 @@ package org.musicpd; -import android.app.Activity; -import android.os.Bundle; +import android.annotation.TargetApi; +import android.app.Notification; +import android.app.PendingIntent; +import android.app.Service; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.ServiceConnection; import android.os.Build; -import android.os.Handler; -import android.os.Message; -import android.widget.TextView; +import android.os.IBinder; +import android.os.PowerManager; +import android.os.RemoteCallbackList; +import android.os.RemoteException; import android.util.Log; +import android.widget.RemoteViews; -public class Main extends Activity implements Runnable { - private static final String TAG = "MPD"; +public class Main extends Service implements Runnable { + private static final String TAG = "Main"; + private static final String REMOTE_ERROR = "MPD process was killed"; + private static final int MAIN_STATUS_ERROR = -1; + private static final int MAIN_STATUS_STOPPED = 0; + private static final int MAIN_STATUS_STARTED = 1; - Thread thread; + private static final int MSG_SEND_STATUS = 0; + private static final int MSG_SEND_LOG = 1; - TextView textView; + private Thread mThread = null; + private int mStatus = MAIN_STATUS_STOPPED; + private boolean mAbort = false; + private String mError = null; + private final RemoteCallbackList<IMainCallback> mCallbacks = new RemoteCallbackList<IMainCallback>(); + private final IBinder mBinder = new MainStub(this); + private PowerManager.WakeLock mWakelock = null; - final Handler quitHandler = new Handler() { - public void handleMessage(Message msg) { - textView.setText("Music Player Daemon has quit"); + 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 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); + } + } - // TODO: what now? restart? + 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; + case MSG_SEND_LOG: + cb.onLog(arg1, (String) obj); + break; + } + } catch (RemoteException e) { } - }; + } + mCallbacks.finishBroadcast(); + } - @Override protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); + private Bridge.LogListener mLogListener = new Bridge.LogListener() { + @Override + public void onLog(int priority, String msg) { + sendMessage(MSG_SEND_LOG, priority, 0, msg); + } + }; + + @Override + public IBinder onBind(Intent intent) { + return mBinder; + } + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + start(); + if (intent != null && intent.getBooleanExtra("wakelock", false)) + setWakelockEnabled(true); + return START_STICKY; + } + @Override + public void run() { if (!Loader.loaded) { - TextView tv = new TextView(this); - tv.setText("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); - setContentView(tv); + 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); + } + + @TargetApi(Build.VERSION_CODES.GINGERBREAD) + private Notification buildNotificationGB(int title, int text, int icon, PendingIntent contentIntent) { + final Notification notification = new Notification(); + notification.icon = R.drawable.icon; + notification.contentView = new RemoteViews(getPackageName(), R.layout.custom_notification_gb); + notification.contentView.setImageViewResource(R.id.image, icon); + notification.contentView.setTextViewText(R.id.title, getText(title)); + notification.contentView.setTextViewText(R.id.text, getText(text)); + notification.contentIntent = contentIntent; + return notification; + } + + @TargetApi(Build.VERSION_CODES.HONEYCOMB) + private Notification buildNotificationHC(int title, int text, int icon, PendingIntent contentIntent) { + return new Notification.Builder(this) + .setContentTitle(getText(title)) + .setContentText(getText(text)) + .setSmallIcon(icon) + .setContentIntent(contentIntent) + .getNotification(); + } + + @TargetApi(Build.VERSION_CODES.JELLY_BEAN) + private Notification buildNotificationJB(int title, int text, int icon, PendingIntent contentIntent) { + return new Notification.Builder(this) + .setContentTitle(getText(title)) + .setContentText(getText(text)) + .setSmallIcon(icon) + .setContentIntent(contentIntent) + .build(); + } + + private void start() { + if (mThread != null) + return; + mThread = new Thread(this); + mThread.start(); + + final Intent mainIntent = new Intent(this, Settings.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); + + Notification notification; + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) + notification = buildNotificationJB( + R.string.notification_title_mpd_running, + R.string.notification_text_mpd_running, + R.drawable.icon, + contentIntent); + else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.HONEYCOMB) + notification = buildNotificationHC( + R.string.notification_title_mpd_running, + R.string.notification_text_mpd_running, + R.drawable.icon, + contentIntent); + else + notification = buildNotificationGB( + R.string.notification_title_mpd_running, + R.string.notification_text_mpd_running, + R.drawable.icon, + contentIntent); + + startForeground(R.string.notification_title_mpd_running, notification); + startService(new Intent(this, Main.class)); + } + + private void stop() { + 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 setWakelockEnabled(boolean enabled) { + if (enabled && mWakelock == null) { + PowerManager pm = (PowerManager)getSystemService(Context.POWER_SERVICE); + mWakelock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, 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); + } + } - if (thread == null || !thread.isAlive()) { - thread = new Thread(this, "NativeMain"); - thread.start(); + private void unregisterCallback(IMainCallback cb) { + if (cb != null) { + mCallbacks.unregister(cb); } + } + + /* + * Client that bind the Main Service in order to send commands and receive callback + */ + public static class Client { - textView = new TextView(this); - textView.setText("Music Player Daemon is running" - + "\nCAUTION: this version is EXPERIMENTAL!"); - setContentView(textView); + public interface Callback { + public void onStarted(); + public void onStopped(); + public void onError(String error); + public void onLog(int priority, String msg); + } + + private boolean mBound = false; + private final Context mContext; + private Callback mCallback; + private IMain mIMain = null; + + private final IMainCallback.Stub mICallback = new IMainCallback.Stub() { + + @Override + public void onStopped() throws RemoteException { + mCallback.onStopped(); + } + + @Override + public void onStarted() throws RemoteException { + mCallback.onStarted(); + } + + @Override + public void onError(String error) throws RemoteException { + mCallback.onError(error); + } + + @Override + public void onLog(int priority, String msg) throws RemoteException { + mCallback.onLog(priority, msg); + } + }; + + private final ServiceConnection mServiceConnection = new ServiceConnection() { + + @Override + public void onServiceConnected(ComponentName name, IBinder service) { + synchronized (this) { + mIMain = IMain.Stub.asInterface(service); + try { + if (mCallback != null) + mIMain.registerCallback(mICallback); + } catch (RemoteException e) { + if (mCallback != null) + mCallback.onError(REMOTE_ERROR); + } + } + } + + @Override + public void onServiceDisconnected(ComponentName name) { + if (mCallback != null) + mCallback.onError(REMOTE_ERROR); + } + }; + + public Client(Context context, Callback cb) throws IllegalArgumentException { + if (context == null) + throw new IllegalArgumentException("Context can't be null"); + mContext = context; + mCallback = cb; + mBound = mContext.bindService(new Intent(mContext, Main.class), mServiceConnection, Context.BIND_AUTO_CREATE); + } + + public boolean start() { + synchronized (this) { + if (mIMain != null) { + try { + mIMain.start(); + return true; + } catch (RemoteException e) { + } + } + return false; + } + } + + public boolean stop() { + synchronized (this) { + if (mIMain != null) { + try { + mIMain.stop(); + return true; + } catch (RemoteException e) { + } + } + return false; + } + } + + public boolean setWakelockEnabled(boolean enabled) { + synchronized (this) { + if (mIMain != null) { + try { + mIMain.setWakelockEnabled(enabled); + return true; + } catch (RemoteException e) { + } + } + return false; + } + } + + public boolean isRunning() { + synchronized (this) { + if (mIMain != null) { + try { + return mIMain.isRunning(); + } catch (RemoteException e) { + } + } + return false; + } + } + + public void release() { + if (mBound) { + synchronized (this) { + if (mIMain != null && mICallback != null) { + try { + if (mCallback != null) + mIMain.unregisterCallback(mICallback); + } catch (RemoteException e) { + } + } + } + mBound = false; + mContext.unbindService(mServiceConnection); + } + } } - @Override public void run() { - Bridge.run(this, null); - quitHandler.sendMessage(quitHandler.obtainMessage()); + /* + * start Main service without any callback + */ + public static void start(Context context, boolean wakelock) { + context.startService(new Intent(context, Main.class).putExtra("wakelock", wakelock)); } } diff --git a/android/src/Settings.java b/android/src/Settings.java new file mode 100644 index 000000000..89f96447d --- /dev/null +++ b/android/src/Settings.java @@ -0,0 +1,145 @@ +/* + * Copyright 2003-2018 The Music Player Daemon Project + * http://www.musicpd.org + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, write to the Free Software Foundation, Inc., + * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + */ + +package org.musicpd; + +import android.app.Activity; +import android.os.Build; +import android.os.Bundle; +import android.os.Handler; +import android.os.Message; +import android.util.Log; +import android.widget.CompoundButton; +import android.widget.CompoundButton.OnCheckedChangeListener; +import android.widget.LinearLayout; +import android.widget.TextView; +import android.widget.ToggleButton; + +public class Settings extends Activity { + private static final String TAG = "Settings"; + private Main.Client mClient; + private TextView mTextView; + private ToggleButton mButton; + private LinearLayout mLayout; + + private static final int MSG_ERROR = 0; + private static final int MSG_STOPPED = 1; + private static final int MSG_STARTED = 2; + + private Handler mHandler = new Handler(new Handler.Callback() { + + @Override + public boolean handleMessage(Message msg) { + switch (msg.what) { + case MSG_ERROR: + Log.d(TAG, "onError"); + final String error = (String) msg.obj; + mTextView.setText("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=" + error); + mButton.setChecked(false); + mButton.setEnabled(false); + break; + case MSG_STOPPED: + Log.d(TAG, "onStopped"); + if (mButton.isEnabled()) // don't overwrite previous error message + mTextView.setText("Music Player Daemon is not running"); + mButton.setEnabled(true); + mButton.setChecked(false); + break; + case MSG_STARTED: + Log.d(TAG, "onStarted"); + mTextView.setText("Music Player Daemon is running" + + "\nCAUTION: this version is EXPERIMENTAL!"); + mButton.setChecked(true); + break; + } + return true; + } + }); + + private OnCheckedChangeListener mOnCheckedChangeListener = new OnCheckedChangeListener() { + + @Override + public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) { + if (mClient != null) { + if (isChecked) + mClient.start(); + else + mClient.stop(); + } + } + }; + + @Override + protected void onCreate(Bundle savedInstanceState) { + mTextView = new TextView(this); + mTextView.setText(""); + + mButton = new ToggleButton(this); + mButton.setOnCheckedChangeListener(mOnCheckedChangeListener); + + mLayout = new LinearLayout(this); + mLayout.setOrientation(LinearLayout.VERTICAL); + mLayout.addView(mButton); + mLayout.addView(mTextView); + + setContentView(mLayout); + + super.onCreate(savedInstanceState); + } + + @Override + protected void onStart() { + mClient = new Main.Client(this, new Main.Client.Callback() { + @Override + public void onStopped() { + mHandler.removeCallbacksAndMessages(null); + mHandler.sendEmptyMessage(MSG_STOPPED); + } + + @Override + public void onStarted() { + mHandler.removeCallbacksAndMessages(null); + mHandler.sendEmptyMessage(MSG_STARTED); + } + + @Override + public void onError(String error) { + mHandler.removeCallbacksAndMessages(null); + mHandler.sendMessage(Message.obtain(mHandler, MSG_ERROR, error)); + } + + @Override + public void onLog(int priority, String msg) { + } + }); + super.onStart(); + } + + @Override + protected void onStop() { + mClient.release(); + mClient = null; + super.onStop(); + } +} |