fixed serial connection, improved ui, improve error handling, ready for release

This commit is contained in:
Mathieu Broillet 2025-02-08 23:06:13 +01:00
parent 0942019869
commit 27204471c7
5 changed files with 163 additions and 111 deletions

View File

@ -23,16 +23,15 @@
<activity
android:name=".MainActivity"
android:exported="true"
android:label="@string/app_name"
android:theme="@style/Theme.Djiffchack">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- <intent-filter>-->
<!-- <action android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED"/>-->
<!-- </intent-filter>-->
<!-- <meta-data android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED" android:resource="@xml/device_filter"/>-->
<intent-filter>
<action android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED"/>
</intent-filter>
<meta-data android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED" android:resource="@xml/device_filter"/>
</activity>
</application>

View File

@ -1,9 +1,14 @@
package ch.mathieubroillet.djiffchack
class Constants {
companion object {
// const val INTENT_ACTION_GRANT_USB_ACCESSORY = "ch.mathieubroillet.djiffchack.USB_ACCESSORY_PERMISSION"
// const val INTENT_ACTION_GRANT_USB_DEVICE = "ch.mathieubroillet.djiffchack.USB_DEVICE_PERMISSION"
const val INTENT_ACTION_GRANT_USB_PERMISSION = "ch.mathieubroillet.djiffchack.USB_PERMISSION"
}
object Constants {
private const val PACKAGE = "ch.mathieubroillet.djiffchack"
const val INTENT_ACTION_GRANT_USB_PERMISSION = "$PACKAGE.USB_PERMISSION"
// The "magic bytes" that enable FCC mode
// The credits goes to @galbb from https://mavicpilots.com/threads/mavic-air-2-switch-to-fcc-mode-using-an-android-app.115027/
val BYTES_1 = byteArrayOf(85, 13, 4, 33, 42, 31, 0, 0, 0, 0, 1, -122, 32)
val BYTES_2 = byteArrayOf(85, 24, 4, 32, 2, 9, 0, 0, 64, 9, 39, 0, 2, 72, 0, -1, -1, 2, 0, 0, 0, 0, -127, 31)
const val GITHUB_URL = "https://github.com/M4TH1EU/DJI-FCC-HACK"
}

View File

@ -6,7 +6,6 @@ import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.hardware.usb.UsbDevice
import android.hardware.usb.UsbDeviceConnection
import android.hardware.usb.UsbManager
import android.os.Bundle
import android.util.Log
@ -25,12 +24,13 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Build
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Clear
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.Button
import androidx.compose.material3.Card
@ -49,7 +49,10 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
@ -66,7 +69,7 @@ import kotlinx.coroutines.launch
class MainActivity : ComponentActivity() {
private lateinit var usbManager: UsbManager
private var usbConnection by mutableStateOf(null as UsbDeviceConnection?)
private var usbConnected by mutableStateOf(false)
private var isPatching by mutableStateOf(false)
override fun onCreate(savedInstanceState: Bundle?) {
@ -81,10 +84,7 @@ class MainActivity : ComponentActivity() {
addAction(Constants.INTENT_ACTION_GRANT_USB_PERMISSION)
}
ContextCompat.registerReceiver(
this,
usbReceiver,
filter,
ContextCompat.RECEIVER_NOT_EXPORTED
this, usbReceiver, filter, ContextCompat.RECEIVER_NOT_EXPORTED
)
// Initial check for USB connection
@ -92,7 +92,7 @@ class MainActivity : ComponentActivity() {
setContent {
DJI_FCC_HACK_Theme {
MainScreen(usbConnection != null, ::refreshUsbConnection, ::sendPatch, isPatching)
MainScreen(usbConnected, ::refreshUsbConnection, ::sendPatch, isPatching)
}
}
}
@ -112,66 +112,69 @@ class MainActivity : ComponentActivity() {
// Check to be sure the device is the initialized DJI Remote (and not another USB device)
if (device.productId != 4128) {
Log.d("USB_CONNECTION", "Device not supported ${device.productId}")
usbConnected = false
return
}
usbConnection = usbManager.openDevice(device)
if (usbConnection == null) {
if (usbManager.openDevice(device) == null) {
Log.d("USB_CONNECTION", "Requesting USB Permission")
requestUsbPermission(device)
} else {
usbConnected = true
}
} else {
usbConnection = null
usbConnected = false
}
}
/**
* Handles sending the patch via USB communication
* Sends the FCC patch to the DJI remote via USB
*/
private fun sendPatch() {
isPatching = true
if (usbConnection == null) {
private fun sendPatch(): Boolean {
// At this point, we assume the USB device is connected and we have permission to access it
if (!usbConnected) {
Toast.makeText(this, "No USB device connected!", Toast.LENGTH_SHORT).show()
isPatching = false
return
return false
}
for (device in usbManager.deviceList.values) {
try {
val probeTable = ProbeTable()
probeTable.addProduct(11427, 4128, CdcAcmSerialDriver::class.java)
probeTable.addProduct(5840, 2174, CdcAcmSerialDriver::class.java)
val probeTable = ProbeTable().apply {
addProduct(11427, 4128, CdcAcmSerialDriver::class.java)
val usbSerialProber = UsbSerialProber(probeTable)
val usbSerialPort = usbSerialProber.probeDevice(device).ports.firstOrNull()
// TODO: not sure which device this is, might be the DJI remote before it's connected
// to drone it seems to use a different device once connected to the drone
// addProduct(5840, 2174, CdcAcmSerialDriver::class.java)
}
if (usbSerialPort == null) {
Toast.makeText(this, "No serial port found", Toast.LENGTH_SHORT).show()
return
}
// Retrieve the custom device (DJI remote) with the correct driver from the probe table above
val driver = UsbSerialProber(probeTable).probeDevice(usbManager.deviceList.values.first())
val deviceConnection = usbManager.openDevice(driver.device)
if (deviceConnection == null) {
Log.e("USB_PATCH", "Error opening USB device")
Toast.makeText(this, "Error opening USB device", Toast.LENGTH_SHORT).show()
return false
}
usbSerialPort.open(usbConnection)
usbSerialPort.setParameters(19200, 8, 1, UsbSerialPort.PARITY_NONE)
usbSerialPort.write(
byteArrayOf(85, 13, 4, 33, 42, 31, 0, 0, 0, 0, 1, -122, 32),
1000
)
usbSerialPort.write(
byteArrayOf(
85, 24, 4, 32, 2, 9, 0, 0, 64, 9, 39, 0, 2, 72, 0, -1, -1, 2, 0, 0, 0, 0,
-127, 31
), 1000
)
} catch (e: Exception) {
Log.e("USB_PATCH", "Error sending patch: ${e.message}")
Toast.makeText(this, "Patch failed: ${e.message}", Toast.LENGTH_SHORT).show()
try {
val deviceSerialPort = driver.ports.firstOrNull()
if (deviceSerialPort == null) {
Log.e("USB_PATCH", "Error opening USB port")
Toast.makeText(this, "Error opening USB port", Toast.LENGTH_SHORT).show()
return false
}
deviceSerialPort.open(deviceConnection)
deviceSerialPort.setParameters(19200, 8, 1, UsbSerialPort.PARITY_NONE)
deviceSerialPort.write(Constants.BYTES_1, 1000)
deviceSerialPort.write(Constants.BYTES_2, 1000)
Toast.makeText(this, "Patched successfully", Toast.LENGTH_LONG).show()
} catch (e: Exception) {
e.printStackTrace()
Toast.makeText(this, "Error: ${e.message}", Toast.LENGTH_LONG).show()
}
isPatching = false
return true
}
/**
@ -221,32 +224,26 @@ class MainActivity : ComponentActivity() {
fun MainScreen(
usbConnected: Boolean,
onRefresh: () -> Unit,
onSendPatch: () -> Unit,
onSendPatch: () -> Boolean,
isPatching: Boolean = false
) {
var buttonText by remember { mutableStateOf("Send FCC Patch") }
var buttonEnabled by remember { mutableStateOf(true) }
val uriHandler = LocalUriHandler.current
Scaffold(
topBar = {
TopAppBar(
title = { Text("DJI FCC Hack") },
actions = {
IconButton(onClick = onRefresh, enabled = !isPatching) {
Icon(Icons.Default.Refresh, contentDescription = "Refresh USB Connection")
}
IconButton(onClick = { /* Open Settings */ }) {
Icon(Icons.Default.MoreVert, contentDescription = "More Options")
}
}
)
}
) { innerPadding ->
Scaffold(topBar = {
TopAppBar(title = { Text("DJI FCC Hack") }, actions = {
IconButton(onClick = onRefresh, enabled = !isPatching) {
Icon(Icons.Default.Refresh, contentDescription = "Refresh USB Connection")
}
})
}) { innerPadding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.padding(16.dp),
.padding(16.dp)
.verticalScroll(rememberScrollState()), // Make the entire screen scrollable
verticalArrangement = Arrangement.spacedBy(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
@ -257,6 +254,61 @@ fun MainScreen(
modifier = Modifier.size(75.dp),
)
// Disclaimer Section
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer)
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = "Disclaimer",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "This app is provided as-is and is not affiliated with DJI. Use at your own risk.",
style = MaterialTheme.typography.bodyMedium
)
}
}
// Instructions Section
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = "Instructions",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(4.dp))
Column {
listOf(
"Turn on the drone and remote and wait a few seconds for them to connect.",
"Connect your phone to the bottom USB port of the remote.",
"Click on 'Send FCC Patch'.",
"Disconnect your phone from the bottom USB port of the remote and connect it to the top USB port."
).forEachIndexed { index, instruction ->
Text(
text = "${index + 1}. $instruction",
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(vertical = 4.dp)
)
}
}
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Note: You have to repeat the process every time you turn on the remote and/or drone.",
style = MaterialTheme.typography.bodyMedium,
fontStyle = FontStyle.Italic
)
}
}
// USB Connection Status
Card(
modifier = Modifier.fillMaxWidth(),
@ -285,9 +337,9 @@ fun MainScreen(
// Send Patch Button
Button(
onClick = {
buttonText = "Successfully patched"
buttonEnabled = false
onSendPatch()
val result = onSendPatch()
buttonText = if (result) "Successfully patched" else "Error patching"
CoroutineScope(Dispatchers.Main).launch {
delay(5000)
@ -306,44 +358,38 @@ fun MainScreen(
Text(buttonText)
}
// Instructions Section
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp)
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = "Instructions",
style = MaterialTheme.typography.titleMedium
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "1. Connect your phone to the bottom port of the RC remote.\n" +
"2. Tap 'Send FCC Patch'.\n" +
"3. Disconnect from the bottom port and connect to the top port.",
style = MaterialTheme.typography.bodyMedium
// Links
Row {
IconButton(onClick = { uriHandler.openUri(Constants.GITHUB_URL) }) {
Image(
painter = painterResource(id = isSystemInDarkTheme().let { if (it) R.drawable.github_light else R.drawable.github_dark }),
contentDescription = "GitHub",
modifier = Modifier.size(24.dp),
)
}
}
// Disclaimer Section
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer)
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = "Disclaimer",
style = MaterialTheme.typography.titleMedium,
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "This app is provided as-is and is not affiliated with DJI. Use at your own risk.",
style = MaterialTheme.typography.bodyMedium
)
}
// Footer
Row {
Text(
text = "Made with ❤️ by ",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.secondary
)
Text(
text = "Mathieu Broillet",
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.secondary,
)
}
Text(
text = "based on the work of @galbb on MavicPilots",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.tertiary,
fontStyle = FontStyle.Italic
)
}
}
}
@ -353,6 +399,6 @@ fun MainScreen(
@Composable
fun PreviewMainScreen() {
DJI_FCC_HACK_Theme {
MainScreen(usbConnected = true, onRefresh = {}, onSendPatch = {}, isPatching = false)
MainScreen(usbConnected = true, onRefresh = {}, onSendPatch = { false }, isPatching = false)
}
}

View File

@ -0,0 +1 @@
<!-- drawable/github.xml --><vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:width="24dp" android:viewportWidth="24" android:viewportHeight="24"><path android:fillColor="#000000" android:pathData="M12,2A10,10 0 0,0 2,12C2,16.42 4.87,20.17 8.84,21.5C9.34,21.58 9.5,21.27 9.5,21C9.5,20.77 9.5,20.14 9.5,19.31C6.73,19.91 6.14,17.97 6.14,17.97C5.68,16.81 5.03,16.5 5.03,16.5C4.12,15.88 5.1,15.9 5.1,15.9C6.1,15.97 6.63,16.93 6.63,16.93C7.5,18.45 8.97,18 9.54,17.76C9.63,17.11 9.89,16.67 10.17,16.42C7.95,16.17 5.62,15.31 5.62,11.5C5.62,10.39 6,9.5 6.65,8.79C6.55,8.54 6.2,7.5 6.75,6.15C6.75,6.15 7.59,5.88 9.5,7.17C10.29,6.95 11.15,6.84 12,6.84C12.85,6.84 13.71,6.95 14.5,7.17C16.41,5.88 17.25,6.15 17.25,6.15C17.8,7.5 17.45,8.54 17.35,8.79C18,9.5 18.38,10.39 18.38,11.5C18.38,15.32 16.04,16.16 13.81,16.41C14.17,16.72 14.5,17.33 14.5,18.26C14.5,19.6 14.5,20.68 14.5,21C14.5,21.27 14.66,21.59 15.17,21.5C19.14,20.16 22,16.42 22,12A10,10 0 0,0 12,2Z" /></vector>

View File

@ -0,0 +1 @@
<!-- drawable/github.xml --><vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:width="24dp" android:viewportWidth="24" android:viewportHeight="24"><path android:fillColor="#ffffff" android:pathData="M12,2A10,10 0 0,0 2,12C2,16.42 4.87,20.17 8.84,21.5C9.34,21.58 9.5,21.27 9.5,21C9.5,20.77 9.5,20.14 9.5,19.31C6.73,19.91 6.14,17.97 6.14,17.97C5.68,16.81 5.03,16.5 5.03,16.5C4.12,15.88 5.1,15.9 5.1,15.9C6.1,15.97 6.63,16.93 6.63,16.93C7.5,18.45 8.97,18 9.54,17.76C9.63,17.11 9.89,16.67 10.17,16.42C7.95,16.17 5.62,15.31 5.62,11.5C5.62,10.39 6,9.5 6.65,8.79C6.55,8.54 6.2,7.5 6.75,6.15C6.75,6.15 7.59,5.88 9.5,7.17C10.29,6.95 11.15,6.84 12,6.84C12.85,6.84 13.71,6.95 14.5,7.17C16.41,5.88 17.25,6.15 17.25,6.15C17.8,7.5 17.45,8.54 17.35,8.79C18,9.5 18.38,10.39 18.38,11.5C18.38,15.32 16.04,16.16 13.81,16.41C14.17,16.72 14.5,17.33 14.5,18.26C14.5,19.6 14.5,20.68 14.5,21C14.5,21.27 14.66,21.59 15.17,21.5C19.14,20.16 22,16.42 22,12A10,10 0 0,0 12,2Z" /></vector>