Created
February 2, 2021 21:33
-
-
Save phhusson/aff39c8db6623673942d053283da3f4f to your computer and use it in GitHub Desktop.
Remote control apps running on a smartphone
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* | |
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 can find a copy of the GNU General Public License at <https://www.gnu.org/licenses/>. | |
*/ | |
/* | |
Usage: | |
Start the attached service | |
Forward TCP port: adb forward 9900 9900 | |
Connect your browser to http://localhost:9900 | |
Enjoy | |
*/ | |
package me.phh.treble.app | |
import android.annotation.SuppressLint | |
import android.app.ActivityOptions | |
import android.app.Service | |
import android.content.ComponentName | |
import android.content.Context | |
import android.content.Intent | |
import android.content.pm.PackageManager | |
import android.content.pm.ResolveInfo | |
import android.graphics.* | |
import android.hardware.display.DisplayManager | |
import android.hardware.display.VirtualDisplay | |
import android.media.* | |
import android.os.* | |
import android.util.Log | |
import android.view.* | |
import fi.iki.elonen.NanoHTTPD | |
import java.io.ByteArrayOutputStream | |
import java.io.File | |
import java.io.FileInputStream | |
import java.io.FileOutputStream | |
import java.lang.StringBuilder | |
import java.util.* | |
import kotlin.concurrent.thread | |
class AppsOverAdb : Service() { | |
private val handler = Handler(HandlerThread("AppsOverADB").also { it.start() }.looper) | |
val componentToDisplay = mutableMapOf<String, VirtualDisplay>() | |
val componentToReader = mutableMapOf<String, ImageReader>() | |
var encoder: MediaCodec? = null | |
@SuppressLint("WrongConstant") | |
private fun getDisplay(component: String): VirtualDisplay { | |
if(componentToDisplay.containsKey(component)) return componentToDisplay[component]!! | |
val dm = getSystemService(DisplayManager::class.java) | |
val saveAsBitmap = "true".toBoolean() | |
val compId = component.hashCode() | |
val width = 720 | |
val height = 1280 | |
val surface = if(saveAsBitmap) { | |
val imageReader = ImageReader.newInstance(width, height, PixelFormat.RGBA_8888, 5) | |
componentToReader[component] = imageReader | |
imageReader.setOnImageAvailableListener({ p0 -> | |
Log.d("PHH-AOA", "Got new image ${System.currentTimeMillis()}") | |
try { | |
val image = p0.acquireLatestImage() ?: return@setOnImageAvailableListener | |
val plane = image.planes[0] | |
val rowPadding = plane.rowStride - plane.pixelStride * image.width | |
val bmp = Bitmap.createBitmap(image.width + rowPadding / plane.pixelStride, image.height, Bitmap.Config.ARGB_8888) | |
bmp.copyPixelsFromBuffer(plane.buffer) | |
image.close() | |
val fos = FileOutputStream("/sdcard/test-tmp-$compId.png") | |
bmp.compress(Bitmap.CompressFormat.PNG, 100, fos) | |
fos.close() | |
File("/sdcard/test-$compId.png").delete() | |
File("/sdcard/test-tmp-$compId.png").renameTo(File("/sdcard/test-$compId.png")) | |
Log.d("PHH-AOA", "New image ${System.currentTimeMillis()}") | |
} catch(t: Throwable) { | |
Log.d("PHH-AOA", "Failed dumping image", t) | |
} | |
}, handler) | |
imageReader.surface | |
} else { | |
// Please note that so far this code path DOES NOT WORK, because muxer can't do real-time muxing! | |
val mimeType = "video/x-vnd.on2.vp9" | |
//encoder = MediaCodec.createEncoderByType(mimeType) | |
encoder = MediaCodec.createByCodecName("OMX.google.vp9.encoder") | |
val muxer = MediaMuxer("/data/data/me.phh.treble.app/test.webm", MediaMuxer.OutputFormat.MUXER_OUTPUT_WEBM) | |
encoder!!.setCallback(object: MediaCodec.Callback() { | |
var trackId: Int = -1 | |
var timeOffset: Long = -1L | |
override fun onOutputBufferAvailable(p0: MediaCodec, p1: Int, p2: MediaCodec.BufferInfo) { | |
try { | |
Log.d("PHH-AOA", "output buffer available (size = ${p2.size} $p1, $p2 to track $trackId") | |
if (trackId == -1) { | |
Log.d("PHH-AOA", "Ignoring buffers") | |
encoder!!.getOutputBuffer(p1) | |
encoder!!.releaseOutputBuffer(p1, false) | |
return | |
} | |
if ((p2.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG) != 0) { | |
Log.d("PHH-AOA", "Got codec config stuff. Ignoring, it is in format already") | |
encoder!!.getOutputBuffer(p1) | |
encoder!!.releaseOutputBuffer(p1, false) | |
return | |
} | |
if (p2.size == 0) { | |
Log.d("PHH-AOA", "Receive empty packet?") | |
encoder!!.getOutputBuffer(p1) | |
encoder!!.releaseOutputBuffer(p1, false) | |
return | |
} | |
val encodedData = encoder!!.getOutputBuffer(p1)!! | |
if(p2.presentationTimeUs != 0L) { | |
if (timeOffset == -1L) { | |
timeOffset = p2.presentationTimeUs | |
p2.presentationTimeUs = 0 | |
} else { | |
p2.presentationTimeUs -= timeOffset | |
} | |
} | |
encodedData.position(p2.offset) | |
encodedData.limit(p2.size + p2.offset) | |
muxer.writeSampleData(trackId, encodedData, p2) | |
encoder!!.releaseOutputBuffer(p1, false) | |
Log.d("PHH-AOA", "Released output buffer") | |
} catch(t: Throwable) { | |
Log.d("PHH-AOA", "Failed reading buffer...", t) | |
} | |
} | |
override fun onInputBufferAvailable(p0: MediaCodec, p1: Int) { | |
Log.d("PHH-AOA", "input buffer available $p1") | |
} | |
override fun onOutputFormatChanged(p0: MediaCodec, p1: MediaFormat) { | |
if(trackId == -1) { | |
trackId = muxer.addTrack(p1) | |
muxer.start() | |
} | |
Log.d("PHH-AOA", "output format changed $p1") | |
} | |
override fun onError(p0: MediaCodec, p1: MediaCodec.CodecException) { | |
Log.d("PHH-AOA", "media error $p1") | |
} | |
}, handler) | |
val format = MediaFormat.createVideoFormat(mimeType, width, height) | |
format.setInteger(MediaFormat.KEY_COLOR_FORMAT, MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface) | |
format.setInteger(MediaFormat.KEY_BIT_RATE, 3000000) | |
format.setInteger(MediaFormat.KEY_FRAME_RATE, 60) | |
format.setInteger("max-fps-to-encoder", 30) | |
format.setInteger("low-latency", 1) | |
format.setInteger(MediaFormat.KEY_REPEAT_PREVIOUS_FRAME_AFTER, 100*1000) | |
format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 5) | |
format.setLong(MediaFormat.KEY_DURATION, 1000*1000*1000L) | |
encoder!!.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE) | |
val surface = encoder!!.createInputSurface() | |
encoder!!.start() | |
surface | |
} | |
Log.d("PHH-AOA", "Creating virtual display") | |
val virtualDisplay = dm.createVirtualDisplay( | |
"Phh-AppsOverAdb-$component", width, height, 320, | |
surface, DisplayManager.VIRTUAL_DISPLAY_FLAG_PUBLIC or (1 shl 6) or (1 shl 10)) //Supports touch and is trusted | |
componentToDisplay[component] = virtualDisplay | |
handler.postDelayed({ | |
try { | |
val s = component.split("/") | |
val comp = ComponentName(s[0], s[1]) | |
val i = Intent().setComponent(comp) | |
val options = ActivityOptions.makeBasic() | |
options.launchDisplayId = virtualDisplay.display.displayId | |
val m = Context::class.java.getMethod("startActivityAsUser", Intent::class.java, Bundle::class.java, UserHandle::class.java) | |
m.invoke(this, i, options.toBundle(), UserHandle.getUserHandleForUid(10105)) | |
} catch(t: Throwable) { | |
Log.d("PHH-AOA", "Failed sending intent", t) | |
} | |
Log.d("PHH-AOA", "Started $component") | |
}, 500L) | |
return virtualDisplay | |
} | |
override fun onCreate() { | |
super.onCreate() | |
thread { | |
try { | |
val httpServer = object : NanoHTTPD(9900) { | |
init { | |
Log.d("PHH-AOA", "Starting http server") | |
start(NanoHTTPD.SOCKET_READ_TIMEOUT, false) | |
Log.d("PHH-AOA", "Started http server") | |
} | |
override fun serve(session: IHTTPSession): Response { | |
Log.d("PHH-AOA", "Receive request for ${session.uri}") | |
try { | |
val component = session.parameters["act"]?.firstOrNull() | |
val displayId = (if(component != null) componentToDisplay[component] else null)?.display?.displayId | |
if (session.uri.endsWith("screen.png")) { | |
val compId = component!!.hashCode() | |
val input = (0..10).map { | |
try { | |
FileInputStream("/sdcard/test-$compId.png") | |
} catch (t: Throwable) { | |
Thread.sleep(10); null | |
} | |
}.filterNotNull().firstOrNull() | |
return newChunkedResponse(Response.Status.OK, "image/png", input).apply { addHeader("Cache-Control", "no-store") } | |
} else if (session.uri.endsWith("/click")) { | |
val reqX = session.parameters["x"]!!.first().toFloat() | |
val reqY = session.parameters["y"]!!.first().toFloat() | |
val im = Class.forName("android.hardware.input.InputManager").getDeclaredMethod("getInstance").invoke(null) | |
val injectEventMethod = im.javaClass.getDeclaredMethod("injectInputEvent", InputEvent::class.java, Int::class.java) | |
val downTime = SystemClock.uptimeMillis() | |
for (action in listOf(MotionEvent.ACTION_DOWN, MotionEvent.ACTION_UP)) { | |
val eventTime = if (action == MotionEvent.ACTION_DOWN) downTime else downTime + 10 | |
val ev = MotionEvent.obtain( | |
downTime, | |
eventTime, | |
action, | |
1, // number of pointers | |
Array(1) { MotionEvent.PointerProperties().apply { toolType = MotionEvent.TOOL_TYPE_FINGER; id = 12 } }, | |
Array(1) { MotionEvent.PointerCoords().apply { x = reqX; y = reqY; pressure = 1.0f; size = 1.0f; } }, | |
0, // metaState | |
0, // buttonState | |
1.0f, 1.0f, //x/y precision | |
0, //deviceId | |
0, //edgeFlags | |
InputDevice.SOURCE_MOUSE, | |
0 //flags | |
) | |
val setDisplayIdMethod = ev.javaClass.getDeclaredMethod("setDisplayId", Int::class.java) | |
setDisplayIdMethod(ev, displayId!!) | |
injectEventMethod.invoke(im, ev, 1 /* INJECT_INPUT_EVENT_MODE_WAIT_FOR_RESULT */) | |
} | |
return newFixedLengthResponse(Response.Status.OK, "text/text", "Yay") | |
} else if (session.uri.endsWith("/wheel")) { | |
val reqX = session.parameters["x"]!!.first().toFloat() | |
val reqY = session.parameters["y"]!!.first().toFloat() | |
val reqDY = session.parameters["dy"]!!.first().toFloat() | |
val im = Class.forName("android.hardware.input.InputManager").getDeclaredMethod("getInstance").invoke(null) | |
val injectEventMethod = im.javaClass.getDeclaredMethod("injectInputEvent", InputEvent::class.java, Int::class.java) | |
val downTime = SystemClock.uptimeMillis() | |
val ev = MotionEvent.obtain( | |
downTime, | |
downTime, | |
MotionEvent.ACTION_SCROLL, | |
1, // number of pointers | |
Array(1) { MotionEvent.PointerProperties().apply { toolType = MotionEvent.TOOL_TYPE_MOUSE; id = 12 } }, | |
Array(1) { MotionEvent.PointerCoords().apply { x = reqX; y = reqY; pressure = 1.0f; size = 1.0f; setAxisValue(MotionEvent.AXIS_VSCROLL, -reqDY)} }, | |
0, // metaState | |
0, // buttonState | |
1.0f, 1.0f, //x/y precision | |
0, //deviceId | |
0, //edgeFlags | |
InputDevice.SOURCE_MOUSE, | |
0 //flags | |
) | |
val setDisplayIdMethod = ev.javaClass.getDeclaredMethod("setDisplayId", Int::class.java) | |
setDisplayIdMethod(ev, displayId!!) | |
injectEventMethod.invoke(im, ev, 1 /* INJECT_INPUT_EVENT_MODE_WAIT_FOR_RESULT */) | |
return newFixedLengthResponse(Response.Status.OK, "text/text", "Yay") | |
} else if(session.uri.endsWith("/keypress")) { | |
val preprocessedKey = session.parameters["y"]!!.first().toString() | |
val key = if(preprocessedKey.toLowerCase() == "enter") "\n" else preprocessedKey | |
Log.d("PHH-AOA", "Pressing '$key'") | |
val im = Class.forName("android.hardware.input.InputManager").getDeclaredMethod("getInstance").invoke(null) | |
val injectEventMethod = im.javaClass.getDeclaredMethod("injectInputEvent", InputEvent::class.java, Int::class.java) | |
val kcm = KeyCharacterMap.load(0) | |
val events = kcm.getEvents(key.toCharArray()) | |
for(ev in events) { | |
val setDisplayIdMethod = ev.javaClass.getDeclaredMethod("setDisplayId", Int::class.java) | |
setDisplayIdMethod(ev, displayId!!) | |
injectEventMethod.invoke(im, ev, 1 /* INJECT_INPUT_EVENT_MODE_WAIT_FOR_RESULT */) | |
} | |
return newFixedLengthResponse(Response.Status.OK, "text/text", "Yay") | |
} else if(session.uri.endsWith("/back")) { | |
Log.d("PHH-AOA", "Pressing BACK") | |
val im = Class.forName("android.hardware.input.InputManager").getDeclaredMethod("getInstance").invoke(null) | |
val injectEventMethod = im.javaClass.getDeclaredMethod("injectInputEvent", InputEvent::class.java, Int::class.java) | |
val downTime = SystemClock.uptimeMillis() | |
for (action in listOf(KeyEvent.ACTION_DOWN, KeyEvent.ACTION_UP)) { | |
val eventTime = if (action == KeyEvent.ACTION_DOWN) downTime else downTime + 10 | |
val ev = KeyEvent(downTime, eventTime, action, KeyEvent.KEYCODE_BACK, 0) | |
val setDisplayIdMethod = ev.javaClass.getDeclaredMethod("setDisplayId", Int::class.java) | |
setDisplayIdMethod(ev, displayId!!) | |
injectEventMethod.invoke(im, ev, 1 /* INJECT_INPUT_EVENT_MODE_WAIT_FOR_RESULT */) | |
} | |
return newFixedLengthResponse(Response.Status.OK, "text/text", "Yay") | |
} else if(session.uri.endsWith("/close")) { | |
val display = componentToDisplay[component]!! | |
display.release() | |
val imReader = componentToReader[component]!! | |
imReader.close() | |
val compId = component!!.hashCode() | |
File("/sdcard/test-$compId.png").delete() | |
return newFixedLengthResponse(Response.Status.OK, "text/text", "Yay") | |
} else if(session.uri.endsWith("display")) { | |
val component = session.parameters["act"]!!.first() | |
getDisplay(component) | |
return newChunkedResponse(Response.Status.OK, "text/html", resources.openRawResource(R.raw.index)) | |
} else { | |
val i = Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER) | |
val activities = packageManager | |
.queryIntentActivities(i, PackageManager.MATCH_ALL) | |
activities.sortWith(ResolveInfo.DisplayNameComparator(packageManager)) | |
val sb = StringBuilder() | |
sb.append("<html><body>") | |
for (resolveInfo in activities) { | |
val pkgName = resolveInfo.activityInfo.applicationInfo.packageName | |
val className = resolveInfo.activityInfo.name | |
sb.append("<a href=\"/display?act=$pkgName/$className\">") | |
val name = resolveInfo.loadLabel(packageManager) | |
val icon = resolveInfo.loadIcon(packageManager) | |
sb.append(name) | |
val bitmap = Bitmap.createBitmap(96, 96, Bitmap.Config.ARGB_8888) | |
val canvas = Canvas(bitmap) | |
icon.setBounds(0, 0, 95, 95) | |
icon.draw(canvas) | |
val os = ByteArrayOutputStream() | |
bitmap.compress(Bitmap.CompressFormat.PNG, 100, os) | |
val byteArray = os.toByteArray() | |
val b64 = Base64.getEncoder().encodeToString(byteArray) | |
sb.append("<img src=\"data:image/png;base64, $b64\" />") | |
sb.append("</a>") | |
sb.append("<br>") | |
} | |
sb.append("</body></html>") | |
return newFixedLengthResponse(Response.Status.OK, "text/html", sb.toString()) | |
} | |
} catch(t: Throwable) { | |
Log.d("PHH", "Hello", t) | |
} | |
return super.serve(session) | |
} | |
} | |
} catch(t: Throwable) { | |
Log.d("PHH", "http server gave bip", t) | |
} | |
} | |
} | |
override fun onBind(p0: Intent?): IBinder? { | |
return null | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment