From d56659ffc82c62d9eee8cfe35643ab8aef670f8f Mon Sep 17 00:00:00 2001 From: Nullptr Date: Wed, 11 Jan 2023 01:14:07 +0800 Subject: [PATCH] Implement service helper --- gradle.properties | 1 + service/build.gradle.kts | 37 +++ service/src/main/AndroidManifest.xml | 2 + .../libxposed/service/RemotePreferences.java | 241 ++++++++++++++ .../libxposed/service/XposedProvider.java | 64 ++++ .../libxposed/service/XposedService.java | 314 ++++++++++++++++++ .../service/XposedServiceHelper.java | 72 ++++ settings.gradle.kts | 2 +- 8 files changed, 732 insertions(+), 1 deletion(-) create mode 100644 gradle.properties create mode 100644 service/build.gradle.kts create mode 100644 service/src/main/AndroidManifest.xml create mode 100644 service/src/main/java/io/github/libxposed/service/RemotePreferences.java create mode 100644 service/src/main/java/io/github/libxposed/service/XposedProvider.java create mode 100644 service/src/main/java/io/github/libxposed/service/XposedService.java create mode 100644 service/src/main/java/io/github/libxposed/service/XposedServiceHelper.java 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")