Skip to content

Commit

Permalink
drop the dependency on USB gadget
Browse files Browse the repository at this point in the history
  • Loading branch information
C10udburst committed May 15, 2024
1 parent 1a236c7 commit b0f349c
Show file tree
Hide file tree
Showing 11 changed files with 249 additions and 19 deletions.
1 change: 0 additions & 1 deletion .idea/misc.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,14 @@ Emulate a USB keyboard to paste text from the clipboard from your phone to your

## Requirements
- Rooted Android device
- [USB Gadget Tool](https://github.com/tejado/android-usb-gadget) switched into hid keyboard mode
<!-- - [USB Gadget Tool](https://github.com/tejado/android-usb-gadget) switched into hid keyboard mode -->
<!-- - On android 10+ you also need clipboard access for background apps enabled via [Riru-ClipboardWhitelist](https://github.com/Kr328/Riru-ClipboardWhitelist) or [xposed-clipboard-whitelist](https://github.com/GamerGirlandCo/xposed-clipboard-whitelist) -->

## Usage
Just copy some text to the clipboard and click the Quick Settings tile to paste it on your computer while connected via USB.

## Acknowledgements
- [USB Gadget Tool](https://github.com/tejado/android-usb-gadget) for the script to switch the USB gadget to hid keyboard mode

## Roadmap
- [ ] Add support for other keyboard layouts
Expand Down
3 changes: 2 additions & 1 deletion app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ android {
minSdk 24
targetSdk 34
versionCode 1
versionName "1.0.1"
versionName "1.0.2"
}

buildTypes {
Expand All @@ -37,4 +37,5 @@ android {

dependencies {
implementation "com.github.topjohnwu.libsu:service:5.2.1"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4"
}
2 changes: 1 addition & 1 deletion app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
android:finishOnCloseSystemDialogs="true"
android:excludeFromRecents="true"
android:launchMode="singleTask"
android:theme="@android:style/Theme.Material.Dialog"
android:theme="@style/WorkingDialog"
android:exported="false" />

<service
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package io.github.cloudburst.cliptype;

interface IUsbRootService {
boolean hidCapable();
String devPath();
boolean gadgetExists();
boolean canWrite();
List<String> enabledGadgets();
void typeUsb(String text);
}
55 changes: 46 additions & 9 deletions app/src/main/java/io/github/cloudburst/cliptype/UsbRootService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -10,26 +10,63 @@ import java.io.File
import java.io.FileOutputStream

val TAG = "UsbRootService"
const val HID_KEYBOARD_GADGET = "/config/usb_gadget/keyboard"

class UsbRootService: RootService() {

class Interface: IUsbRootService.Stub() {
override fun hidCapable(): Boolean {
// find /dev/hidg*
// return true if found
val devFolder = File("/dev")
val hidgFiles = devFolder.listFiles { file -> file.name.startsWith("hidg") } ?: return false
return hidgFiles.isNotEmpty()
override fun devPath(): String? {
return File("/dev/").listFiles { _, s -> s.startsWith("hidg") }?.lastOrNull()?.path
}

override fun canWrite(): Boolean {
try {
val hidPath = devPath() ?: return false
val hidDev = FileOutputStream(hidPath, true)
hidDev.write(byteArrayOf(0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00))
hidDev.flush()
hidDev.close()
return true
} catch (e: Exception) {
return false
}
}

override fun gadgetExists(): Boolean {
val hidGadget = File(HID_KEYBOARD_GADGET)
return hidGadget.exists() && hidGadget.listFiles()?.isNotEmpty() == true
}

override fun enabledGadgets(): MutableList<String> {
val gadgets = File("/config/usb_gadget").listFiles {
file -> file.isDirectory
} ?: return mutableListOf()
return gadgets.filter { file ->
try {
val udc = file.listFiles { f -> f.name == "UDC" }?.firstOrNull()
?: return@filter false
if (!udc.isFile || !udc.canRead() || udc.length() == 0L) return@filter false
val text = udc.readText()
if (text.trim().isBlank()) return@filter false
if (text.trim() == "not set") return@filter false
return@filter true
} catch (e: Exception) {
return@filter false
}
}.map { file -> file.path }.toMutableList()
}

override fun typeUsb(text: String) {
Log.d(TAG, "Will type: $text")

val devFolder = File("/dev/")
val hidFile = devFolder.listFiles { file -> file.name.startsWith("hidg") }?.last() ?: return
val hidPath = devPath()
if (hidPath == null) {
Log.e(TAG, "HID device not found")
return
}

val kbdMap = UsbKbdMap()
val hidDev = FileOutputStream(hidFile, true)
val hidDev = FileOutputStream(hidPath, true)
for (c in text) {
hidDev.write(kbdMap.getScancode(c))
Thread.sleep(10)
Expand Down
116 changes: 111 additions & 5 deletions app/src/main/java/io/github/cloudburst/cliptype/WorkingActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,19 @@ import android.util.Log
import android.widget.TextView
import com.topjohnwu.superuser.Shell
import com.topjohnwu.superuser.ipc.RootService
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeout

class WorkingActivity : Activity() {
private var connection: UsbRootService.Connection? = null

private var clipboardContents: CharSequence = ""

private var enabledGadgets: List<String> = listOf()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_working)
Expand All @@ -32,11 +40,21 @@ class WorkingActivity : Activity() {

private fun onConnected(connection: UsbRootService.Connection) {
val binder = connection.binder ?: return finish()
if (!binder.hidCapable()) {
findViewById<TextView>(R.id.statusTextView).text = getString(R.string.usb_qs_desc_hid_unavailable)
} else {
binder.typeUsb(clipboardContents.toString())
finish()
CoroutineScope(Dispatchers.Default).launch {
if (!ensureDevice(binder)) {
runOnUiThread {
findViewById<TextView>(R.id.statusTextView).text = getString(R.string.usb_qs_desc_hid_unavailable)
}
revertDevice(binder)
} else {
if (waitForWrite(binder)) {
binder.typeUsb(clipboardContents.toString())
}
revertDevice(binder)
runOnUiThread {
finish()
}
}
}
}

Expand All @@ -46,4 +64,92 @@ class WorkingActivity : Activity() {
RootService.unbind(it)
}
}

private suspend fun ensureDevice(binder: IUsbRootService): Boolean {
if (binder.devPath() != null) {
Log.d(TAG, "Device already exists")
return true
}

if (!Shell.getShell().isRoot) {
Log.e(TAG, "Root access required")
return false
}

enabledGadgets = binder.enabledGadgets()

Log.d(TAG, "Enabled gadgets: $enabledGadgets")

if (!binder.gadgetExists()) {
Log.d(TAG, "Creating gadget")
Shell.cmd(resources.openRawResource(R.raw.create_gadget)).exec().let {
Log.d(TAG, "Creating gadget: ${it.out}, ${it.err}")
if (!it.isSuccess) return false
}
try {
withTimeout(1000) {
while (!binder.gadgetExists()) {
delay(100)
}
}
} catch (e: TimeoutCancellationException) {
Log.e(TAG, "Gadget creation timed out")
return false
}
}

// deactivate all
Shell.cmd("find /config/usb_gadget/ -name UDC -type f -exec sh -c 'echo \"\" > \"$@\"' _ {} \\;\n").exec().let {
Log.d(TAG, "Deactivate all: ${it.out}, ${it.err}")
if (!it.isSuccess) return false
}

// activate keyboard
Shell.cmd("getprop sys.usb.controller > ${HID_KEYBOARD_GADGET}/UDC\n").exec().let {
Log.d(TAG, "Activate keyboard: ${it.out}, ${it.err}")
if (!it.isSuccess) return false
}

try {
withTimeout(3000) {
while (binder.devPath() == null) {
delay(100)
}
}
} catch (e: TimeoutCancellationException) {
Log.e(TAG, "Device creation timed out")
return false
}


return true
}

private fun revertDevice(binder: IUsbRootService) {
// deactivate all
Shell.cmd("find /config/usb_gadget/ -name UDC -type f -exec sh -c 'echo \"\" > \"$@\"' _ {} \\;\n").exec().let {
Log.d(TAG, "Deactivate all: ${it.out}, ${it.err}")
}

// activate previous
enabledGadgets.forEach {
Shell.cmd("getprop sys.usb.controller > $it/UDC\n").exec().let {
Log.d(TAG, "Activate $it: ${it.out}, ${it.err}")
}
}
}

private suspend fun waitForWrite(binder: IUsbRootService): Boolean {
return try {
withTimeout(3000) {
while (!binder.canWrite()) {
delay(100)
}
}
true
} catch (e: TimeoutCancellationException) {
Log.e(TAG, "Device write timed out")
false
}
}
}
59 changes: 59 additions & 0 deletions app/src/main/res/raw/create_gadget.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
#!/bin/sh

# Source: https://github.com/tejado/android-usb-gadget/blob/master/app/src/main/assets/usbGadgetProfiles/MouseKeyboard

CONFIGFS_DIR="/config"
GADGETS_PATH="${CONFIGFS_DIR}/usb_gadget"

GADGET="keyboard"
GADGET_PATH=${GADGETS_PATH}/${GADGET}

CONFIG_PATH="$GADGET_PATH/configs/c.1/"
STRINGS_PATH="$GADGET_PATH/strings/0x409/"

mkdir -p $CONFIG_PATH
mkdir -p $STRINGS_PATH

mkdir -p $GADGET_PATH/functions/hid.keyboard
cd $GADGET_PATH/functions/hid.keyboard

# HID protocol (according to USB spec: 1 for keyboard)
echo 1 > protocol
# device subclass
echo 1 > subclass
# number of bytes per record
echo 8 > report_length

# writing report descriptor
echo -ne \\x05\\x01\\x09\\x06\\xa1\\x01\\x05\\x07\\x19\\xe0\\x29\\xe7\\x15\\x00\\x25\\x01\\x75\\x01\\x95\\x08\\x81\\x02\\x95\\x01\\x75\\x08\\x81\\x03\\x95\\x05\\x75\\x01\\x05\\x08\\x19\\x01\\x29\\x05\\x91\\x02\\x95\\x01\\x75\\x03\\x91\\x03\\x95\\x06\\x75\\x08\\x15\\x00\\x25\\x65\\x05\\x07\\x19\\x00\\x29\\x65\\x81\\x00\\xc0 > report_desc

#mkdir -p $GADGET_PATH/functions/hid.mouse
#cd $GADGET_PATH/functions/hid.mouse
#
## HID protocol (according to USB spec: 2 for mouse)
#echo 2 > protocol
## device subclass
#echo 1 > subclass
## number of bytes per record
#echo 4 > report_length
#
## writing report descriptor
#echo -ne \\x05\\x01\\x09\\x02\\xa1\\x01\\x09\\x01\\xa1\\x00\\x05\\x09\\x19\\x01\\x29\\x05\\x15\\x00\\x25\\x01\\x95\\x05\\x75\\x01\\x81\\x02\\x95\\x01\\x75\\x03\\x81\\x01\\x05\\x01\\x09\\x30\\x09\\x31\\x09\\x38\\x15\\x81\\x25\\x7F\\x75\\x08\\x95\\x03\\x81\\x06\\xc0\\xc0 > report_desc
#

cd $GADGET_PATH
echo 0x046a > idVendor
echo 0x002a > idProduct

cd $STRINGS_PATH
echo "cloudburst" > manufacturer
echo "ClipType" > product
echo "42" > serialnumber

cd $CONFIG_PATH
echo 100 > MaxPower
mkdir -p strings/0x409
echo "Configuration" > strings/0x409/configuration

ln -s ${GADGET_PATH}/functions/hid.keyboard $CONFIG_PATH/hid.keyboard
#ln -s ${GADGET_PATH}/functions/hid.mouse $CONFIG_PATH/hid.mouse
7 changes: 7 additions & 0 deletions app/src/main/res/values-v28/styles.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>

<style name="WorkingDialog" parent="@android:style/Theme.Material.Dialog">
<item name="android:dialogCornerRadius">16dp</item>
</style>
</resources>
9 changes: 9 additions & 0 deletions app/src/main/res/values-v31/styles.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>

<style name="WorkingDialog" parent="@android:style/Theme.Material.Dialog">
<item name="android:dialogCornerRadius">16dp</item>
<item name="android:background">@android:color/system_neutral1_900</item>
<item name="android:color">@android:color/system_neutral1_10</item>
</style>
</resources>
7 changes: 7 additions & 0 deletions app/src/main/res/values/styles.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>

<style name="WorkingDialog" parent="@android:style/Theme.Material.Dialog">
<item name="android:background">@android:color/system_neutral1_10</item>
</style>
</resources>

0 comments on commit b0f349c

Please sign in to comment.