Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitmodules
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
[submodule "libmagica/src/main/jni/external/lsplt"]
path = libmagica/src/main/jni/external/lsplt
url = https://github.com/LSPosed/LSPlt.git
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,23 @@ adb shell start
./gradlew :app:iR
```

## EXEC

```sh
adb shell am broadcast -n io.github.vvb2060.puellamagi/.CommandReceiver -a io.github.vvb2060.puellamagi.action.EXEC --es cmd id
adb shell am broadcast -n io.github.vvb2060.puellamagi/.CommandReceiver -a io.github.vvb2060.puellamagi.action.EXEC --es cmd whoami
adb shell 'am broadcast -n io.github.vvb2060.puellamagi/.CommandReceiver -a io.github.vvb2060.puellamagi.action.EXEC --es cmd "su -v"'
```

For commands with parameters, enclose them in quotation marks or run them directly in the adb shell.

```sh
adb shell
am broadcast -n io.github.vvb2060.puellamagi/.CommandReceiver -a io.github.vvb2060.puellamagi.action.EXEC --es cmd "su -v"
```

The command result is returned in `Broadcast completed` output `data=`.

## License

Public domain
28 changes: 16 additions & 12 deletions app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,15 @@ plugins {
}

android {
compileSdkVersion 32
buildToolsVersion '32.0.0'
ndkVersion '23.1.7779620'
ndkVersion '28.2.13676358'
defaultConfig {
applicationId 'io.github.vvb2060.puellamagi'
minSdkVersion 29
targetSdkVersion 32
versionCode 2
versionName '1.1'
compileSdk 36
versionCode 3
versionName '1.2'
}
namespace 'io.github.vvb2060.puellamagi'
buildTypes {
release {
minifyEnabled true
Expand All @@ -21,11 +20,10 @@ android {
proguardFiles 'proguard-rules.pro'
}
}
externalNativeBuild {
ndkBuild.path 'src/main/jni/Android.mk'
}
buildFeatures {
viewBinding true
aidl true
buildConfig true
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_11
Expand All @@ -36,10 +34,16 @@ android {
excludes += '**'
}
}
lintOptions {
checkReleaseBuilds false
}
dependenciesInfo {
includeInApk false
}
lint {
checkReleaseBuilds false
}
enableKotlin = false
}

dependencies {
implementation project(':libmagica')
implementation("org.lsposed.hiddenapibypass:hiddenapibypass:6.1")
}
38 changes: 36 additions & 2 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="io.github.vvb2060.puellamagi">
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />

<queries>
<package android:name="com.topjohnwu.magisk" />
<package android:name="io.github.vvb2060.magisk" />
</queries>

<application
android:name=".App"
android:allowBackup="true"
android:directBootAware="true"
android:fullBackupOnly="true"
android:icon="@android:drawable/sym_def_app_icon"
android:label="Magica"
Expand All @@ -17,9 +22,38 @@
android:zygotePreloadName="io.github.vvb2060.puellamagi.AppZygote">
<service
android:name=".MagicaService"
android:directBootAware="true"
android:exported="false"
android:isolatedProcess="true"
android:useAppZygote="true" />

<service
android:name=".CommandService"
android:directBootAware="true"
android:exported="false"
android:foregroundServiceType="specialUse" />

<receiver
android:name=".BootCompletedReceiver"
android:directBootAware="true"
android:enabled="true"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.LOCKED_BOOT_COMPLETED" />
<action android:name="android.intent.action.BOOT_COMPLETED" />
</intent-filter>
</receiver>

<receiver
android:name=".CommandReceiver"
android:directBootAware="true"
android:enabled="true"
android:exported="true">
<intent-filter>
<action android:name="io.github.vvb2060.puellamagi.action.EXEC" />
</intent-filter>
</receiver>

<activity
android:name=".MainActivity"
android:exported="true">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,5 @@ import io.github.vvb2060.puellamagi.IRemoteProcess;
interface IRemoteService {
IRemoteProcess getRemoteProcess();
List<RunningAppProcessInfo> getRunningAppProcesses();
String execCommand(String command);
}
6 changes: 5 additions & 1 deletion app/src/main/java/io/github/vvb2060/puellamagi/App.java
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,13 @@
public class App extends Application {
public static final String TAG = "Magica";
public static Context application;
public static Context deviceProtectedContext;
public static IRemoteService server;

public App() {
@Override
public void onCreate() {
super.onCreate();
application = this;
deviceProtectedContext = createDeviceProtectedStorageContext();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package io.github.vvb2060.puellamagi;

import static io.github.vvb2060.puellamagi.App.TAG;

import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.util.Log;

public final class BootCompletedReceiver extends BroadcastReceiver {
@Override
public void onReceive(Context context, Intent intent) {
if (intent == null) {
return;
}
var action = intent.getAction();
if (!Intent.ACTION_LOCKED_BOOT_COMPLETED.equals(action)
&& !Intent.ACTION_BOOT_COMPLETED.equals(action)) {
return;
}
try {
context.startService(new Intent(context, MagicaService.class));
Log.i(TAG, "MagicaService started from boot action: " + action);
} catch (RuntimeException e) {
Log.e(TAG, "Failed to start MagicaService from boot action: " + action, e);
}
try {
context.startService(new Intent(context, CommandService.class));
Log.i(TAG, "CommandService started from boot action: " + action);
} catch (RuntimeException e) {
Log.e(TAG, "Failed to start CommandService from boot action: " + action, e);
}
}
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package io.github.vvb2060.puellamagi;

import android.util.Log;

import io.github.vvb2060.magica.lib.MagicaRoot;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;

final class CommandExecutor {

private CommandExecutor() {
}

static String normalize(String command) {
if (command == null) {
return "";
}
return command.trim();
}

static String execute(String command) throws IOException {
var normalized = normalize(command);
if (normalized.isEmpty()) {
throw new IllegalArgumentException("Command must not be empty");
}

MagicaRoot.root();
var process = Runtime.getRuntime().exec(new String[]{"sh", "-c", normalized});
var stdout = readAll(process.getInputStream());
var stderr = readAll(process.getErrorStream());

try {
int code = process.waitFor();
var output = stdout.isEmpty() ? stderr : stdout;
Log.d(App.TAG, output + " (exit=" + code + ")");
if (output.isEmpty()) {
output = "<empty output>";
}
return "exit=" + code + ", output=" + output;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IOException("Command interrupted", e);
} finally {
process.destroy();
}
}

private static String readAll(java.io.InputStream in) throws IOException {
var sb = new StringBuilder();
try (var reader = new BufferedReader(new InputStreamReader(in))) {
String line;
while ((line = reader.readLine()) != null) {
if (sb.length() > 0) {
sb.append('\n');
}
sb.append(line);
}
}
return sb.toString();
}
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package io.github.vvb2060.puellamagi;

import static io.github.vvb2060.puellamagi.App.TAG;

import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import android.os.ResultReceiver;
import android.util.Log;

public final class CommandReceiver extends BroadcastReceiver {
public static final String ACTION_EXEC = "io.github.vvb2060.puellamagi.action.EXEC";
public static final String EXTRA_COMMAND = "cmd";

// Keys used to pass command results through the ResultReceiver bundle.
static final String KEY_RESULT = "result";

@Override
public void onReceive(Context context, Intent intent) {
if (intent == null || !ACTION_EXEC.equals(intent.getAction())) {
return;
}

var command = intent.getStringExtra(EXTRA_COMMAND);

// BroadcastReceivers are NOT allowed to bind to services.
// Instead, we start MagicaService (isolatedProcess + useAppZygote, already rooted)
// and pass a ResultReceiver so the service can send the command output back.
var pending = goAsync();
var receiver = new ResultReceiver(new Handler(Looper.getMainLooper())) {
@Override
protected void onReceiveResult(int resultCode, Bundle resultData) {
pending.setResultCode(resultCode);
if (resultData != null) {
pending.setResultData(resultData.getString(KEY_RESULT, ""));
}
pending.finish();
}
};

// Target CommandService (non-isolated): startForegroundService is allowed from
// background, unlike startService. CommandService holds a persistent binding to
// the isolated MagicaService (which cannot be started directly) and proxies the
// command there.
var serviceIntent = new Intent(context, CommandService.class);
serviceIntent.setAction(CommandService.ACTION_EXEC);
serviceIntent.putExtra(CommandService.EXTRA_COMMAND, command);
serviceIntent.putExtra(CommandService.EXTRA_RESULT_RECEIVER, receiver);
try {
context.startService(serviceIntent);
} catch (Exception e) {
Log.e(TAG, "Failed to start CommandService", e);
pending.setResultCode(1);
pending.setResultData("exec failed: " + e.getMessage());
pending.finish();
}
}
}

Loading