diff options
-rw-r--r-- | Makefile.am | 25 | ||||
-rw-r--r-- | NEWS | 8 | ||||
-rw-r--r-- | android/AndroidManifest.xml | 29 | ||||
-rw-r--r-- | android/res/layout/custom_notification_gb.xml | 22 | ||||
-rw-r--r-- | android/res/layout/log_item.xml | 5 | ||||
-rw-r--r-- | android/res/layout/settings.xml | 37 | ||||
-rw-r--r-- | android/res/values/strings.xml | 6 | ||||
-rw-r--r-- | android/src/Bridge.java | 8 | ||||
-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/Receiver.java | 41 | ||||
-rw-r--r-- | android/src/Settings.java | 254 | ||||
-rw-r--r-- | src/LogBackend.cxx | 5 | ||||
-rw-r--r-- | src/Main.cxx | 7 | ||||
-rw-r--r-- | src/Main.hxx | 3 | ||||
-rw-r--r-- | src/android/LogListener.cxx | 46 | ||||
-rw-r--r-- | src/android/LogListener.hxx | 32 | ||||
-rw-r--r-- | src/storage/plugins/CurlStorage.cxx | 14 |
19 files changed, 927 insertions, 53 deletions
diff --git a/Makefile.am b/Makefile.am index 69b8056f0..aa37de4bb 100644 --- a/Makefile.am +++ b/Makefile.am @@ -329,7 +329,8 @@ libjava_a_SOURCES = \ noinst_LIBRARIES += libandroid.a libandroid_a_SOURCES = \ src/android/Context.cxx src/android/Context.hxx \ - src/android/Environment.cxx src/android/Environment.hxx + src/android/Environment.cxx src/android/Environment.hxx \ + src/android/LogListener.cxx src/android/LogListener.hxx libandroid_a_CPPFLAGS = $(AM_CPPFLAGS) -Iandroid/build/include noinst_LIBRARIES += libmain.a @@ -353,6 +354,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 @@ -360,16 +362,26 @@ 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/%,$@) $@ -android/build/resources.apk: $(ANDROID_XML_RES_COPIES) android/build/res/drawable/icon.png +android/build/resources.apk: $(ANDROID_XML_RES_COPIES) android/build/res/drawable/icon.png android/build/res/drawable/notification_icon.png @$(MKDIR_P) android/build/gen $(AAPT) package -f -m --auto-add-overlay \ --custom-package org.musicpd \ @@ -382,15 +394,15 @@ 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) \ + -h android/build/include \ -d $(JAVA_CLASSFILES_DIR) $^ $(DX) --dex --output $@ $(JAVA_CLASSFILES_DIR) android/build/include/org_musicpd_Bridge.h: android/build/classes.dex - javah -classpath $(ANDROID_SDK_PLATFORM_DIR)/android.jar:$(JAVA_CLASSFILES_DIR) -d $(@D) org.musicpd.Bridge BUILT_SOURCES = android/build/include/org_musicpd_Bridge.h @@ -403,6 +415,9 @@ android/build/res/drawable/icon.png: mpd.svg mkdir -p $(@D) rsvg-convert --width=48 --height=48 $< -o $@ +android/build/res/drawable/notification_icon.png: android/build/res/drawable/icon.png + convert $< -colorspace Gray -gamma 2.2 $@ + .DELETE_ON_ERROR: android/build/unsigned.apk android/build/unsigned.apk: android/build/classes.dex android/build/resources.apk android/build/lib/$(ANDROID_ABI)/libmpd.so cp android/build/resources.apk $@ @@ -44,6 +44,14 @@ ver 0.21 (not yet released) * systemd watchdog support * require GCC 6 +ver 0.20.22 (not yet released) +* storage + - curl: URL-encode paths +* Android + - now runs as a service + - add button to start/stop MPD + - add option to auto-start on boot + ver 0.20.21 (2018/08/17) * database - proxy: add "password" setting diff --git a/android/AndroidManifest.xml b/android/AndroidManifest.xml index b6abd1124..5dddf034e 100644 --- a/android/AndroidManifest.xml +++ b/android/AndroidManifest.xml @@ -2,23 +2,32 @@ <manifest xmlns:android="http://schemas.android.com/apk/res/android" package="org.musicpd" android:installLocation="auto" - android:versionCode="20" - android:versionName="0.20.21"> + android:versionCode="21" + android:versionName="0.20.22"> - <uses-sdk android:minSdkVersion="21" android:targetSdkVersion="21"/> + <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"> + <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/> + <uses-permission android:name="android.permission.WAKE_LOCK"/> + <uses-permission android:name="android.permission.INTERNET"/> + <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" /> + + <application android:allowBackup="true" + android:icon="@drawable/icon" + android:label="@string/app_name"> + <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> + <receiver android:name=".Receiver"> + <intent-filter> + <action android:name="android.intent.action.BOOT_COMPLETED" /> + </intent-filter> + </receiver> + <service android:name=".Main" android:process=":main"/> </application> - <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/> - <uses-permission android:name="android.permission.WAKE_LOCK"/> - <uses-permission android:name="android.permission.INTERNET"/> </manifest> 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/layout/log_item.xml b/android/res/layout/log_item.xml new file mode 100644 index 000000000..e6e74c913 --- /dev/null +++ b/android/res/layout/log_item.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<TextView xmlns:android="http://schemas.android.com/apk/res/android" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:typeface="monospace" /> diff --git a/android/res/layout/settings.xml b/android/res/layout/settings.xml new file mode 100644 index 000000000..46e471b05 --- /dev/null +++ b/android/res/layout/settings.xml @@ -0,0 +1,37 @@ +<?xml version="1.0" encoding="utf-8"?> +<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:orientation="vertical" > + + <ToggleButton + android:id="@+id/run" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:textOn="@string/toggle_button_run_on" + android:textOff="@string/toggle_button_run_off" /> + + <CheckBox + android:id="@+id/run_on_boot" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:text="@string/checkbox_run_on_boot" /> + + <CheckBox + android:id="@+id/wakelock" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:text="@string/checkbox_wakelock" /> + + <TextView + android:id="@+id/status" + android:layout_width="wrap_content" + android:layout_height="wrap_content" /> + + <ListView + android:id="@+id/log_list" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:paddingTop="10dip" /> + +</LinearLayout> diff --git a/android/res/values/strings.xml b/android/res/values/strings.xml index 416c8de9f..fc5a15bda 100644 --- a/android/res/values/strings.xml +++ b/android/res/values/strings.xml @@ -2,4 +2,10 @@ <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> + <string name="toggle_button_run_on">MPD is running</string> + <string name="toggle_button_run_off">MPD is not running</string> + <string name="checkbox_run_on_boot">Run MPD automatically on boot</string> + <string name="checkbox_wakelock">Prevent suspend when MPD is running (Wakelock)</string> </resources> diff --git a/android/src/Bridge.java b/android/src/Bridge.java index be8eabb6b..fad919204 100644 --- a/android/src/Bridge.java +++ b/android/src/Bridge.java @@ -25,6 +25,12 @@ import android.content.Context; * Bridge to native code. */ public class Bridge { - public static native void run(Context context); + + /* used by jni */ + public interface LogListener { + public void onLog(int priority, String msg); + } + + public static native void run(Context context, LogListener logListener); public static native void shutdown(); } 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 da64a1976..5a5d9d048 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.notification_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.notification_icon, + contentIntent); + else + notification = buildNotificationGB( + R.string.notification_title_mpd_running, + R.string.notification_text_mpd_running, + R.drawable.notification_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); - 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/Receiver.java b/android/src/Receiver.java new file mode 100644 index 000000000..e24a29fbc --- /dev/null +++ b/android/src/Receiver.java @@ -0,0 +1,41 @@ + +/* + * Copyright (C) 2003-2014 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.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.util.Log; + +public class Receiver extends BroadcastReceiver { + @Override + public void onReceive(Context context, Intent intent) { + Log.d("Receiver", "onReceive: " + intent); + if (intent.getAction() == "android.intent.action.BOOT_COMPLETED") { + if (Settings.Preferences.getBoolean(context, + Settings.Preferences.KEY_RUN_ON_BOOT, false)) { + final boolean wakelock = Settings.Preferences.getBoolean(context, + Settings.Preferences.KEY_WAKELOCK, false); + Main.start(context, wakelock); + } + } + } +} diff --git a/android/src/Settings.java b/android/src/Settings.java new file mode 100644 index 000000000..69b5305e2 --- /dev/null +++ b/android/src/Settings.java @@ -0,0 +1,254 @@ +/* + * 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 java.util.LinkedList; + +import android.app.Activity; +import android.content.Context; +import android.content.SharedPreferences; +import android.content.SharedPreferences.Editor; +import android.os.Bundle; +import android.os.Handler; +import android.os.Message; +import android.util.Log; +import android.widget.ArrayAdapter; +import android.widget.CheckBox; +import android.widget.CompoundButton; +import android.widget.CompoundButton.OnCheckedChangeListener; +import android.widget.ListView; +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 mTextStatus; + private ToggleButton mRunButton; + private boolean mFirstRun; + private LinkedList<String> mLogListArray = new LinkedList<String>(); + private ListView mLogListView; + private ArrayAdapter<String> mLogListAdapter; + + private static final int MAX_LOGS = 500; + + private static final int MSG_ERROR = 0; + private static final int MSG_STOPPED = 1; + private static final int MSG_STARTED = 2; + private static final int MSG_LOG = 3; + + public static class Preferences { + public static final String KEY_RUN_ON_BOOT ="run_on_boot"; + public static final String KEY_WAKELOCK ="wakelock"; + + public static SharedPreferences get(Context context) { + return context.getSharedPreferences(TAG, MODE_PRIVATE); + } + + public static void putBoolean(Context context, String key, boolean value) { + final SharedPreferences prefs = get(context); + + if (prefs == null) + return; + final Editor editor = prefs.edit(); + editor.putBoolean(key, value); + editor.apply(); + } + + public static boolean getBoolean(Context context, String key, boolean defValue) { + final SharedPreferences prefs = get(context); + + return prefs != null ? prefs.getBoolean(key, defValue) : defValue; + } + } + + private Handler mHandler = new Handler(new Handler.Callback() { + @Override + public boolean handleMessage(Message msg) { + switch (msg.what) { + case MSG_ERROR: + Log.d(TAG, "onError"); + + mClient.release(); + connectClient(); + + mRunButton.setEnabled(false); + mRunButton.setChecked(false); + + mTextStatus.setText((String)msg.obj); + mFirstRun = true; + break; + case MSG_STOPPED: + Log.d(TAG, "onStopped"); + mRunButton.setEnabled(true); + if (!mFirstRun && Preferences.getBoolean(Settings.this, Preferences.KEY_RUN_ON_BOOT, false)) + mRunButton.setChecked(true); + else + mRunButton.setChecked(false); + mFirstRun = true; + break; + case MSG_STARTED: + Log.d(TAG, "onStarted"); + mRunButton.setChecked(true); + mFirstRun = true; + mTextStatus.setText("CAUTION: this version is EXPERIMENTAL!"); // XXX + break; + case MSG_LOG: + if (mLogListArray.size() > MAX_LOGS) + mLogListArray.remove(0); + String priority; + switch (msg.arg1) { + case Log.DEBUG: + priority = "D"; + break; + case Log.ERROR: + priority = "E"; + break; + case Log.INFO: + priority = "I"; + break; + case Log.VERBOSE: + priority = "V"; + break; + case Log.WARN: + priority = "W"; + break; + default: + priority = ""; + } + mLogListArray.add(priority + "/ " + (String)msg.obj); + mLogListAdapter.notifyDataSetChanged(); + + break; + } + return true; + } + }); + + private final OnCheckedChangeListener mOnRunChangeListener = new OnCheckedChangeListener() { + @Override + public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) { + if (mClient != null) { + if (isChecked) { + mClient.start(); + if (Preferences.getBoolean(Settings.this, + Preferences.KEY_WAKELOCK, false)) + mClient.setWakelockEnabled(true); + } else { + mClient.stop(); + } + } + } + }; + + private final OnCheckedChangeListener mOnRunOnBootChangeListener = new OnCheckedChangeListener() { + @Override + public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) { + Preferences.putBoolean(Settings.this, Preferences.KEY_RUN_ON_BOOT, isChecked); + if (isChecked && mClient != null && !mRunButton.isChecked()) + mRunButton.setChecked(true); + } + }; + + private final OnCheckedChangeListener mOnWakelockChangeListener = new OnCheckedChangeListener() { + @Override + public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) { + Preferences.putBoolean(Settings.this, Preferences.KEY_WAKELOCK, isChecked); + if (mClient != null && mClient.isRunning()) + mClient.setWakelockEnabled(isChecked); + } + }; + + @Override + protected void onCreate(Bundle savedInstanceState) { + setContentView(R.layout.settings); + mRunButton = (ToggleButton) findViewById(R.id.run); + mRunButton.setOnCheckedChangeListener(mOnRunChangeListener); + + mTextStatus = (TextView) findViewById(R.id.status); + + mLogListAdapter = new ArrayAdapter<String>(this, R.layout.log_item, mLogListArray); + + mLogListView = (ListView) findViewById(R.id.log_list); + mLogListView.setAdapter(mLogListAdapter); + mLogListView.setTranscriptMode(ListView.TRANSCRIPT_MODE_NORMAL); + + CheckBox checkbox = (CheckBox) findViewById(R.id.run_on_boot); + checkbox.setOnCheckedChangeListener(mOnRunOnBootChangeListener); + if (Preferences.getBoolean(this, Preferences.KEY_RUN_ON_BOOT, false)) + checkbox.setChecked(true); + + checkbox = (CheckBox) findViewById(R.id.wakelock); + checkbox.setOnCheckedChangeListener(mOnWakelockChangeListener); + if (Preferences.getBoolean(this, Preferences.KEY_WAKELOCK, false)) + checkbox.setChecked(true); + + super.onCreate(savedInstanceState); + } + + private void connectClient() { + mClient = new Main.Client(this, new Main.Client.Callback() { + + private void removeMessages() { + /* don't remove log messages */ + mHandler.removeMessages(MSG_STOPPED); + mHandler.removeMessages(MSG_STARTED); + mHandler.removeMessages(MSG_ERROR); + } + + @Override + public void onStopped() { + removeMessages(); + mHandler.sendEmptyMessage(MSG_STOPPED); + } + + @Override + public void onStarted() { + removeMessages(); + mHandler.sendEmptyMessage(MSG_STARTED); + } + + @Override + public void onError(String error) { + removeMessages(); + mHandler.sendMessage(Message.obtain(mHandler, MSG_ERROR, error)); + } + + @Override + public void onLog(int priority, String msg) { + mHandler.sendMessage(Message.obtain(mHandler, MSG_LOG, priority, 0, msg)); + } + }); + } + + @Override + protected void onStart() { + mFirstRun = false; + connectClient(); + super.onStart(); + } + + @Override + protected void onStop() { + mClient.release(); + mClient = null; + super.onStop(); + } +} diff --git a/src/LogBackend.cxx b/src/LogBackend.cxx index 767170d2a..16e784efb 100644 --- a/src/LogBackend.cxx +++ b/src/LogBackend.cxx @@ -34,6 +34,8 @@ #ifdef ANDROID #include <android/log.h> +#include "android/LogListener.hxx" +#include "Main.hxx" static int ToAndroidLogLevel(LogLevel log_level) noexcept @@ -179,6 +181,9 @@ Log(const Domain &domain, LogLevel level, const char *msg) noexcept #ifdef ANDROID __android_log_print(ToAndroidLogLevel(level), "MPD", "%s: %s", domain.GetName(), msg); + if (logListener != nullptr) + logListener->OnLog(Java::GetEnv(), ToAndroidLogLevel(level), + "%s: %s", domain.GetName(), msg); #else if (level < log_threshold) diff --git a/src/Main.cxx b/src/Main.cxx index 9a56ad4e9..4e1a65b85 100644 --- a/src/Main.cxx +++ b/src/Main.cxx @@ -95,6 +95,7 @@ #include "java/File.hxx" #include "android/Environment.hxx" #include "android/Context.hxx" +#include "android/LogListener.hxx" #include "fs/FileSystem.hxx" #include "org_musicpd_Bridge.h" #endif @@ -128,6 +129,7 @@ static constexpr unsigned DEFAULT_BUFFER_BEFORE_PLAY = 10; #ifdef ANDROID Context *context; +LogListener *logListener; #endif Instance *instance; @@ -723,16 +725,19 @@ mpd_main_after_fork(const ConfigData &raw_config, const Config &config) gcc_visibility_default JNIEXPORT void JNICALL -Java_org_musicpd_Bridge_run(JNIEnv *env, jclass, jobject _context) +Java_org_musicpd_Bridge_run(JNIEnv *env, jclass, jobject _context, jobject _logListener) { Java::Init(env); Java::File::Initialise(env); Environment::Initialise(env); context = new Context(env, _context); + if (_logListener != nullptr) + logListener = new LogListener(env, _logListener); mpd_main(0, nullptr); + delete logListener; delete context; Environment::Deinitialise(env); } diff --git a/src/Main.hxx b/src/Main.hxx index 8fb267919..47c330ac0 100644 --- a/src/Main.hxx +++ b/src/Main.hxx @@ -25,7 +25,10 @@ class Context; struct Instance; #ifdef ANDROID +#include "android/LogListener.hxx" + extern Context *context; +extern LogListener *logListener; #endif extern Instance *instance; diff --git a/src/android/LogListener.cxx b/src/android/LogListener.cxx new file mode 100644 index 000000000..c7fed8811 --- /dev/null +++ b/src/android/LogListener.cxx @@ -0,0 +1,46 @@ +/* + * 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. + */ + +#include "config.h" +#include "LogListener.hxx" +#include "java/Class.hxx" +#include "java/String.hxx" +#include "util/AllocatedString.hxx" +#include "util/FormatString.hxx" + +void +LogListener::OnLog(JNIEnv *env, int priority, const char *fmt, ...) const +{ + assert(env != nullptr); + + Java::Class cls(env, env->GetObjectClass(Get())); + + jmethodID method = env->GetMethodID(cls, "onLog", + "(ILjava/lang/String;)V"); + + assert(method); + + va_list args; + va_start(args, fmt); + const auto log = FormatStringV(fmt, args); + va_end(args); + + env->CallVoidMethod(Get(), method, priority, + Java::String(env, log.c_str()).Get()); +} diff --git a/src/android/LogListener.hxx b/src/android/LogListener.hxx new file mode 100644 index 000000000..2c81078fe --- /dev/null +++ b/src/android/LogListener.hxx @@ -0,0 +1,32 @@ +/* + * 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. + */ + +#ifndef MPD_ANDROID_LOG_LISTENER_HXX +#define MPD_ANDROID_LOG_LISTENER_HXX + +#include "java/Object.hxx" + +class LogListener : public Java::Object { +public: + LogListener(JNIEnv *env, jobject obj):Java::Object(env, obj) {} + + void OnLog(JNIEnv *env, int priority, const char *fmt, ...) const; +}; + +#endif diff --git a/src/storage/plugins/CurlStorage.cxx b/src/storage/plugins/CurlStorage.cxx index b924c7cd8..37d00b6c7 100644 --- a/src/storage/plugins/CurlStorage.cxx +++ b/src/storage/plugins/CurlStorage.cxx @@ -36,6 +36,7 @@ #include "thread/Cond.hxx" #include "util/ASCII.hxx" #include "util/ChronoUtil.hxx" +#include "util/IterableSplitString.hxx" #include "util/RuntimeError.hxx" #include "util/StringCompare.hxx" #include "util/StringFormat.hxx" @@ -77,9 +78,18 @@ CurlStorage::MapUTF8(const char *uri_utf8) const noexcept if (StringIsEmpty(uri_utf8)) return base; - // TODO: escape the given URI + CurlEasy easy; + std::string path_esc; + + for (auto elt: IterableSplitString(uri_utf8, '/')) { + char *elt_esc = easy.Escape(elt.data, elt.size); + if (!path_esc.empty()) + path_esc.push_back('/'); + path_esc += elt_esc; + curl_free(elt_esc); + } - return PathTraitsUTF8::Build(base.c_str(), uri_utf8); + return PathTraitsUTF8::Build(base.c_str(), path_esc.c_str()); } const char * |