diff --git a/gradle.properties b/gradle.properties
new file mode 100644
index 0000000..5bac8ac
--- /dev/null
+++ b/gradle.properties
@@ -0,0 +1 @@
+android.useAndroidX=true
diff --git a/service/build.gradle.kts b/service/build.gradle.kts
new file mode 100644
index 0000000..7a71661
--- /dev/null
+++ b/service/build.gradle.kts
@@ -0,0 +1,37 @@
+plugins {
+ id("com.android.library")
+}
+
+android {
+ namespace = "io.github.libxposed.service"
+ compileSdk = 33
+ buildToolsVersion = "33.0.1"
+
+ defaultConfig {
+ minSdk = 21
+ targetSdk = 33
+ }
+
+ buildFeatures {
+ androidResources = false
+ buildConfig = false
+ }
+
+ buildTypes {
+ release {
+ isMinifyEnabled = true
+ }
+ }
+
+ compileOptions {
+ isCoreLibraryDesugaringEnabled = true
+ sourceCompatibility = JavaVersion.VERSION_11
+ targetCompatibility = JavaVersion.VERSION_11
+ }
+}
+
+dependencies {
+ implementation(project(":interface"))
+ compileOnly("androidx.annotation:annotation:1.5.0")
+ coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:1.2.2")
+}
diff --git a/service/src/main/AndroidManifest.xml b/service/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..8072ee0
--- /dev/null
+++ b/service/src/main/AndroidManifest.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/service/src/main/java/io/github/libxposed/service/RemotePreferences.java b/service/src/main/java/io/github/libxposed/service/RemotePreferences.java
new file mode 100644
index 0000000..a4da0e4
--- /dev/null
+++ b/service/src/main/java/io/github/libxposed/service/RemotePreferences.java
@@ -0,0 +1,241 @@
+package io.github.libxposed.service;
+
+import android.content.SharedPreferences;
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.Looper;
+import android.os.RemoteException;
+import android.util.Log;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.TreeMap;
+import java.util.WeakHashMap;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.locks.Lock;
+import java.util.concurrent.locks.ReentrantLock;
+
+@SuppressWarnings("unchecked")
+public final class RemotePreferences implements SharedPreferences {
+
+ private static final String TAG = "RemotePreferences";
+ private static final Object CONTENT = new Object();
+ private static final Lock LOCK = new ReentrantLock();
+ private static final Handler HANDLER = new Handler(Looper.getMainLooper());
+
+ private final XposedService mService;
+ private final String mGroup;
+ private final Map mMap = new ConcurrentHashMap<>();
+ private final Map mListeners = Collections.synchronizedMap(new WeakHashMap<>());
+
+ private volatile boolean isDeleted = false;
+
+ private RemotePreferences(XposedService service, String group) {
+ this.mService = service;
+ this.mGroup = group;
+ }
+
+ @Nullable
+ static RemotePreferences newInstance(XposedService service, String group) throws RemoteException {
+ Bundle output = service.getRaw().requestRemotePreferences(group);
+ if (output == null) return null;
+ var prefs = new RemotePreferences(service, group);
+ if (output.containsKey("map")) {
+ prefs.mMap.putAll((Map) output.getSerializable("map"));
+ }
+ return prefs;
+ }
+
+ void setDeleted() {
+ this.isDeleted = true;
+ }
+
+ @Override
+ public Map getAll() {
+ return new TreeMap<>(mMap);
+ }
+
+ @Nullable
+ @Override
+ public String getString(String key, @Nullable String defValue) {
+ var v = (String) mMap.getOrDefault(key, defValue);
+ if (v != null) return v;
+ return defValue;
+ }
+
+ @Nullable
+ @Override
+ public Set getStringSet(String key, @Nullable Set defValues) {
+ var v = (Set) mMap.getOrDefault(key, defValues);
+ if (v != null) return v;
+ return defValues;
+ }
+
+ @Override
+ public int getInt(String key, int defValue) {
+ var v = (Integer) mMap.getOrDefault(key, defValue);
+ if (v != null) return v;
+ return defValue;
+ }
+
+ @Override
+ public long getLong(String key, long defValue) {
+ var v = (Long) mMap.getOrDefault(key, defValue);
+ if (v != null) return v;
+ return defValue;
+ }
+
+ @Override
+ public float getFloat(String key, float defValue) {
+ var v = (Float) mMap.getOrDefault(key, defValue);
+ if (v != null) return v;
+ return defValue;
+ }
+
+ @Override
+ public boolean getBoolean(String key, boolean defValue) {
+ var v = (Boolean) mMap.getOrDefault(key, defValue);
+ if (v != null) return v;
+ return defValue;
+ }
+
+ @Override
+ public boolean contains(String key) {
+ return mMap.containsKey(key);
+ }
+
+ @Override
+ public void registerOnSharedPreferenceChangeListener(OnSharedPreferenceChangeListener listener) {
+ mListeners.put(listener, CONTENT);
+ }
+
+ @Override
+ public void unregisterOnSharedPreferenceChangeListener(OnSharedPreferenceChangeListener listener) {
+ mListeners.remove(listener);
+ }
+
+ @Override
+ public Editor edit() {
+ return new Editor();
+ }
+
+ public class Editor implements SharedPreferences.Editor {
+
+ private final HashSet mDelete = new HashSet<>();
+ private final HashMap mPut = new HashMap<>();
+
+ private void put(String key, @NonNull Object value) {
+ mDelete.remove(key);
+ mPut.put(key, value);
+ }
+
+ @Override
+ public SharedPreferences.Editor putString(String key, @Nullable String value) {
+ if (value == null) remove(key);
+ else put(key, value);
+ return this;
+ }
+
+ @Override
+ public SharedPreferences.Editor putStringSet(String key, @Nullable Set values) {
+ if (values != null) values.forEach(v -> putString(key, v));
+ return this;
+ }
+
+ @Override
+ public SharedPreferences.Editor putInt(String key, int value) {
+ put(key, value);
+ return this;
+ }
+
+ @Override
+ public SharedPreferences.Editor putLong(String key, long value) {
+ put(key, value);
+ return this;
+ }
+
+ @Override
+ public SharedPreferences.Editor putFloat(String key, float value) {
+ put(key, value);
+ return this;
+ }
+
+ @Override
+ public SharedPreferences.Editor putBoolean(String key, boolean value) {
+ put(key, value);
+ return this;
+ }
+
+ @Override
+ public SharedPreferences.Editor remove(String key) {
+ mDelete.add(key);
+ mPut.remove(key);
+ return this;
+ }
+
+ @Override
+ public SharedPreferences.Editor clear() {
+ mDelete.clear();
+ mPut.clear();
+ return this;
+ }
+
+ private void doUpdate(boolean throwing) {
+ mService.deletionLock.readLock().lock();
+ try {
+ if (isDeleted) {
+ throw new IllegalStateException("This preferences group has been deleted");
+ }
+ mDelete.forEach(mMap::remove);
+ mMap.putAll(mPut);
+ List changes = new ArrayList<>(mDelete.size() + mMap.size());
+ changes.addAll(mDelete);
+ changes.addAll(mMap.keySet());
+ synchronized (mListeners) {
+ for (var key : changes) {
+ mListeners.keySet().forEach(listener -> listener.onSharedPreferenceChanged(RemotePreferences.this, key));
+ }
+ }
+
+ var bundle = new Bundle();
+ bundle.putSerializable("delete", mDelete);
+ bundle.putSerializable("put", mPut);
+ try {
+ mService.getRaw().updateRemotePreferences(mGroup, bundle);
+ } catch (RemoteException e) {
+ if (throwing) {
+ throw new RuntimeException(e);
+ } else {
+ Log.e(TAG, "Failed to update remote preferences", e);
+ }
+ }
+ } finally {
+ mService.deletionLock.readLock().unlock();
+ }
+ }
+
+ @Override
+ public boolean commit() {
+ if (!LOCK.tryLock()) return false;
+ try {
+ doUpdate(true);
+ return true;
+ } finally {
+ LOCK.unlock();
+ }
+ }
+
+ @Override
+ public void apply() {
+ HANDLER.post(() -> doUpdate(false));
+ }
+ }
+}
diff --git a/service/src/main/java/io/github/libxposed/service/XposedProvider.java b/service/src/main/java/io/github/libxposed/service/XposedProvider.java
new file mode 100644
index 0000000..98f56d5
--- /dev/null
+++ b/service/src/main/java/io/github/libxposed/service/XposedProvider.java
@@ -0,0 +1,64 @@
+package io.github.libxposed.service;
+
+import android.content.ContentProvider;
+import android.content.ContentValues;
+import android.database.Cursor;
+import android.net.Uri;
+import android.os.Bundle;
+import android.os.IBinder;
+import android.util.Log;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+public final class XposedProvider extends ContentProvider {
+
+ private static final String TAG = "XposedProvider";
+
+ @Override
+ public boolean onCreate() {
+ return false;
+ }
+
+ @Nullable
+ @Override
+ public Cursor query(@NonNull Uri uri, @Nullable String[] projection, @Nullable String selection, @Nullable String[] selectionArgs, @Nullable String sortOrder) {
+ return null;
+ }
+
+ @Nullable
+ @Override
+ public String getType(@NonNull Uri uri) {
+ return null;
+ }
+
+ @Nullable
+ @Override
+ public Uri insert(@NonNull Uri uri, @Nullable ContentValues values) {
+ return null;
+ }
+
+ @Override
+ public int delete(@NonNull Uri uri, @Nullable String selection, @Nullable String[] selectionArgs) {
+ return 0;
+ }
+
+ @Override
+ public int update(@NonNull Uri uri, @Nullable ContentValues values, @Nullable String selection, @Nullable String[] selectionArgs) {
+ return 0;
+ }
+
+ @Nullable
+ @Override
+ public Bundle call(@NonNull String method, @Nullable String arg, @Nullable Bundle extras) {
+ if (method.equals(IXposedService.SEND_BINDER) && extras != null) {
+ IBinder binder = extras.getBinder("binder");
+ if (binder != null) {
+ Log.d(TAG, "binder received: " + binder);
+ XposedServiceHelper.onBinderReceived(binder);
+ }
+ return new Bundle();
+ }
+ return null;
+ }
+}
diff --git a/service/src/main/java/io/github/libxposed/service/XposedService.java b/service/src/main/java/io/github/libxposed/service/XposedService.java
new file mode 100644
index 0000000..454a5bd
--- /dev/null
+++ b/service/src/main/java/io/github/libxposed/service/XposedService.java
@@ -0,0 +1,314 @@
+package io.github.libxposed.service;
+
+import static android.os.ParcelFileDescriptor.MODE_READ_ONLY;
+
+import android.content.SharedPreferences;
+import android.os.Bundle;
+import android.os.RemoteException;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import java.io.FileInputStream;
+import java.io.FileOutputStream;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.locks.ReentrantReadWriteLock;
+
+@SuppressWarnings("unused")
+public final class XposedService {
+
+ public final static class ServiceException extends RuntimeException {
+ private ServiceException(RemoteException e) {
+ super("Xposed service error", e);
+ }
+ }
+
+ public enum Privilege {
+ /**
+ * Unknown privilege value
+ */
+ FRAMEWORK_PRIVILEGE_UNKNOWN,
+
+ /**
+ * The framework is running as root
+ */
+ FRAMEWORK_PRIVILEGE_ROOT,
+
+ /**
+ * The framework is running in a container with a fake system_server
+ */
+ FRAMEWORK_PRIVILEGE_CONTAINER,
+
+ /**
+ * The framework is running as a different app, which may have at most shell permission
+ */
+ FRAMEWORK_PRIVILEGE_APP,
+
+ /**
+ * The framework is embedded in the hooked app, which means {@link #getRemotePreferences} and remote file streams will be null
+ */
+ FRAMEWORK_PRIVILEGE_EMBEDDED
+ }
+
+ private final IXposedService mService;
+ private final Map mRemotePrefs = new HashMap<>();
+
+ final ReentrantReadWriteLock deletionLock = new ReentrantReadWriteLock();
+
+ XposedService(IXposedService service) {
+ mService = service;
+ }
+
+ IXposedService getRaw() {
+ return mService;
+ }
+
+ /**
+ * Get the Xposed API version of current implementation
+ *
+ * @return API version
+ * @throws ServiceException If the service is dead or error occurred
+ */
+ public int getAPIVersion() {
+ try {
+ return mService.getAPIVersion();
+ } catch (RemoteException e) {
+ throw new ServiceException(e);
+ }
+ }
+
+ /**
+ * Get the Xposed framework name of current implementation
+ *
+ * @return Framework name
+ * @throws ServiceException If the service is dead or error occurred
+ */
+ @NonNull
+ public String getFrameworkName() {
+ try {
+ return mService.getFrameworkName();
+ } catch (RemoteException e) {
+ throw new ServiceException(e);
+ }
+ }
+
+ /**
+ * Get the Xposed framework version of current implementation
+ *
+ * @return Framework version
+ * @throws ServiceException If the service is dead or error occurred
+ */
+ @NonNull
+ public String getFrameworkVersion() {
+ try {
+ return mService.getFrameworkVersion();
+ } catch (RemoteException e) {
+ throw new ServiceException(e);
+ }
+ }
+
+ /**
+ * Get the Xposed framework version code of current implementation
+ *
+ * @return Framework version code
+ * @throws ServiceException If the service is dead or error occurred
+ */
+ public long getFrameworkVersionCode() {
+ try {
+ return mService.getFrameworkVersionCode();
+ } catch (RemoteException e) {
+ throw new ServiceException(e);
+ }
+ }
+
+ /**
+ * Get the Xposed framework privilege of current implementation
+ *
+ * @return Framework privilege
+ * @throws ServiceException If the service is dead or error occurred
+ */
+ @NonNull
+ public Privilege getFrameworkPrivilege() {
+ try {
+ int value = mService.getFrameworkPrivilege();
+ return (value >= 0 && value <= 3) ? Privilege.values()[value + 1] : Privilege.FRAMEWORK_PRIVILEGE_UNKNOWN;
+ } catch (RemoteException e) {
+ throw new ServiceException(e);
+ }
+ }
+
+ /**
+ * Additional methods provided by specific Xposed framework
+ *
+ * @param name Featured method name
+ * @param args Featured method arguments
+ * @return Featured method result
+ * @throws UnsupportedOperationException If the framework does not provide a method with given name
+ * @throws ServiceException If the service is dead or error occurred
+ * @deprecated Normally, modules should never rely on implementation details about the Xposed framework,
+ * but if really necessary, this method can be used to acquire such information
+ */
+ @Deprecated
+ @Nullable
+ public Bundle featuredMethod(@NonNull String name, @Nullable Bundle args) throws UnsupportedOperationException {
+ try {
+ return mService.featuredMethod(name, args);
+ } catch (RemoteException e) {
+ throw new ServiceException(e);
+ }
+ }
+
+ /**
+ * Get the application scope of current module
+ *
+ * @return Module scope
+ * @throws ServiceException If the service is dead or error occurred
+ */
+ @NonNull
+ public List getScope() {
+ try {
+ return mService.getScope();
+ } catch (RemoteException e) {
+ throw new ServiceException(e);
+ }
+ }
+
+ /**
+ * Request to add a new app to the module scope
+ *
+ * @param packageName Package name of the app to be added
+ * @param callback Callback to be invoked when the request is completed or error occurred
+ * @throws ServiceException If the service is dead or error occurred
+ */
+ public void requestScope(@NonNull String packageName, @NonNull IXposedScopeCallback callback) {
+ try {
+ mService.requestScope(packageName, callback);
+ } catch (RemoteException e) {
+ throw new ServiceException(e);
+ }
+ }
+
+ /**
+ * Remove an app from the module scope
+ *
+ * @param packageName Package name of the app to be added
+ * @return null if successful, or non-null with error message
+ * @throws ServiceException If the service is dead or error occurred
+ */
+ @Nullable
+ public String removeScope(@NonNull String packageName) {
+ try {
+ return mService.removeScope(packageName);
+ } catch (RemoteException e) {
+ throw new ServiceException(e);
+ }
+ }
+
+ /**
+ * Get remote preferences from Xposed framework
+ *
+ * @param group Group name
+ * @return The preferences, null if the framework is embedded
+ * @throws ServiceException If the service is dead or error occurred
+ */
+ @Nullable
+ public SharedPreferences getRemotePreferences(@NonNull String group) {
+ return mRemotePrefs.computeIfAbsent(group, k -> {
+ try {
+ return RemotePreferences.newInstance(this, k);
+ } catch (RemoteException e) {
+ throw new ServiceException(e);
+ }
+ });
+ }
+
+ /**
+ * Delete a group of remote preferences
+ *
+ * @param group Group name
+ * @throws ServiceException If the service is dead or error occurred
+ */
+ public void deleteRemotePreferences(@NonNull String group) {
+ deletionLock.writeLock().lock();
+ try {
+ mService.deleteRemotePreferences(group);
+ mRemotePrefs.computeIfPresent(group, (k, v) -> {
+ v.setDeleted();
+ return null;
+ });
+ } catch (RemoteException e) {
+ throw new ServiceException(e);
+ } finally {
+ deletionLock.writeLock().unlock();
+ }
+ }
+
+ /**
+ * Open an InputStream to read a file from the module's shared data directory
+ *
+ * @param name File name
+ * @return The InputStream, null if the framework is embedded
+ * @throws ServiceException If the service is dead or error occurred
+ */
+ @Nullable
+ public FileInputStream openRemoteFileInput(@NonNull String name) {
+ try {
+ var file = mService.openRemoteFile(name, MODE_READ_ONLY);
+ if (file == null) return null;
+ return new FileInputStream(file.getFileDescriptor());
+ } catch (RemoteException e) {
+ throw new ServiceException(e);
+ }
+ }
+
+ /**
+ * Open an OutputStream to write a file to the module's shared data directory
+ *
+ * @param name File name
+ * @param mode Operating mode
+ * @return The OutputStream, null if the framework is embedded
+ * @throws ServiceException If the service is dead or error occurred
+ */
+ @Nullable
+ public FileOutputStream openRemoteFileOutput(@NonNull String name, int mode) {
+ try {
+ var file = mService.openRemoteFile(name, mode);
+ if (file == null) return null;
+ return new FileOutputStream(file.getFileDescriptor());
+ } catch (RemoteException e) {
+ throw new ServiceException(e);
+ }
+ }
+
+ /**
+ * Delete a file in the module's shared data directory
+ *
+ * @param name File name
+ * @return true if successful, false if failed or the framework is embedded
+ * @throws ServiceException If the service is dead or error occurred
+ */
+ public boolean deleteRemoteFile(@NonNull String name) {
+ try {
+ return mService.deleteRemoteFile(name);
+ } catch (RemoteException e) {
+ throw new ServiceException(e);
+ }
+ }
+
+ /**
+ * List all files in the module's shared data directory
+ *
+ * @return The file list, null if the framework is embedded
+ * @throws ServiceException If the service is dead or error occurred
+ */
+ @Nullable
+ public String[] listRemoteFiles() {
+ try {
+ return mService.listRemoteFiles();
+ } catch (RemoteException e) {
+ throw new ServiceException(e);
+ }
+ }
+}
diff --git a/service/src/main/java/io/github/libxposed/service/XposedServiceHelper.java b/service/src/main/java/io/github/libxposed/service/XposedServiceHelper.java
new file mode 100644
index 0000000..071ada3
--- /dev/null
+++ b/service/src/main/java/io/github/libxposed/service/XposedServiceHelper.java
@@ -0,0 +1,72 @@
+package io.github.libxposed.service;
+
+import android.os.IBinder;
+import android.util.Log;
+
+import java.util.HashSet;
+import java.util.Set;
+
+@SuppressWarnings("unused")
+public final class XposedServiceHelper {
+
+ public interface ServiceListener {
+ /**
+ * Callback when the service is connected
+ * This method could be called multiple times if multiple Xposed frameworks exist
+ *
+ * @param service Service instance
+ */
+ void onServiceBind(XposedService service);
+
+ /**
+ * Callback when the service is dead
+ */
+ void onServiceDied(XposedService service);
+ }
+
+ private static final String TAG = "XposedServiceHelper";
+ private static final Set mCache = new HashSet<>();
+ private static ServiceListener mListener = null;
+
+ static void onBinderReceived(IBinder binder) {
+ if (binder == null) return;
+ synchronized (mCache) {
+ try {
+ var service = new XposedService(IXposedService.Stub.asInterface(binder));
+ if (mListener == null) {
+ mCache.add(service);
+ } else {
+ binder.linkToDeath(() -> mListener.onServiceDied(service), 0);
+ mListener.onServiceBind(service);
+ }
+ } catch (Throwable t) {
+ Log.e(TAG, "onBinderReceived", t);
+ }
+ }
+ }
+
+ /**
+ * Register a ServiceListener to receive service binders from Xposed frameworks
+ * This method should only be called once
+ *
+ * @param listener Listener to register
+ */
+ public static void registerListener(ServiceListener listener) {
+ synchronized (mCache) {
+ mListener = listener;
+ if (!mCache.isEmpty()) {
+ for (var it = mCache.iterator(); it.hasNext(); ) {
+ try {
+ var service = it.next();
+ service.getRaw().asBinder().linkToDeath(() -> mListener.onServiceDied(service), 0);
+ mListener.onServiceBind(service);
+ } catch (Throwable t) {
+ Log.e(TAG, "registerListener", t);
+ it.remove();
+ }
+ }
+ mCache.clear();
+ }
+ }
+ }
+}
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 2f3a1be..a0c4cac 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -19,4 +19,4 @@ dependencyResolutionManagement {
rootProject.name = "libxposed"
-include(":interface")
+include(":interface", ":service")