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 <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"
android:label="@string/app_name"
android:theme="@style/Theme.Djiffchack"> android:theme="@style/Theme.Djiffchack">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
<!-- <intent-filter>--> <intent-filter>
<!-- <action android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED"/>--> <action android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED"/>
<!-- </intent-filter>--> </intent-filter>
<!-- <meta-data android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED" android:resource="@xml/device_filter"/>--> <meta-data android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED" android:resource="@xml/device_filter"/>
</activity> </activity>
</application> </application>

View File

@ -1,9 +1,14 @@
package ch.mathieubroillet.djiffchack package ch.mathieubroillet.djiffchack
class Constants {
companion object { object Constants {
// const val INTENT_ACTION_GRANT_USB_ACCESSORY = "ch.mathieubroillet.djiffchack.USB_ACCESSORY_PERMISSION" private const val PACKAGE = "ch.mathieubroillet.djiffchack"
// const val INTENT_ACTION_GRANT_USB_DEVICE = "ch.mathieubroillet.djiffchack.USB_DEVICE_PERMISSION" const val INTENT_ACTION_GRANT_USB_PERMISSION = "$PACKAGE.USB_PERMISSION"
const val INTENT_ACTION_GRANT_USB_PERMISSION = "ch.mathieubroillet.djiffchack.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.Intent
import android.content.IntentFilter import android.content.IntentFilter
import android.hardware.usb.UsbDevice import android.hardware.usb.UsbDevice
import android.hardware.usb.UsbDeviceConnection
import android.hardware.usb.UsbManager import android.hardware.usb.UsbManager
import android.os.Bundle import android.os.Bundle
import android.util.Log 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.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Build import androidx.compose.material.icons.filled.Build
import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Clear import androidx.compose.material.icons.filled.Clear
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.Card import androidx.compose.material3.Card
@ -49,7 +49,10 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.painterResource 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.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
@ -66,7 +69,7 @@ import kotlinx.coroutines.launch
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
private lateinit var usbManager: UsbManager 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) private var isPatching by mutableStateOf(false)
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
@ -81,10 +84,7 @@ class MainActivity : ComponentActivity() {
addAction(Constants.INTENT_ACTION_GRANT_USB_PERMISSION) addAction(Constants.INTENT_ACTION_GRANT_USB_PERMISSION)
} }
ContextCompat.registerReceiver( ContextCompat.registerReceiver(
this, this, usbReceiver, filter, ContextCompat.RECEIVER_NOT_EXPORTED
usbReceiver,
filter,
ContextCompat.RECEIVER_NOT_EXPORTED
) )
// Initial check for USB connection // Initial check for USB connection
@ -92,7 +92,7 @@ class MainActivity : ComponentActivity() {
setContent { setContent {
DJI_FCC_HACK_Theme { 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) // Check to be sure the device is the initialized DJI Remote (and not another USB device)
if (device.productId != 4128) { if (device.productId != 4128) {
Log.d("USB_CONNECTION", "Device not supported ${device.productId}") Log.d("USB_CONNECTION", "Device not supported ${device.productId}")
usbConnected = false
return return
} }
usbConnection = usbManager.openDevice(device) if (usbManager.openDevice(device) == null) {
if (usbConnection == null) {
Log.d("USB_CONNECTION", "Requesting USB Permission") Log.d("USB_CONNECTION", "Requesting USB Permission")
requestUsbPermission(device) requestUsbPermission(device)
} else {
usbConnected = true
} }
} else { } 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() { private fun sendPatch(): Boolean {
isPatching = true // At this point, we assume the USB device is connected and we have permission to access it
if (!usbConnected) {
if (usbConnection == null) {
Toast.makeText(this, "No USB device connected!", Toast.LENGTH_SHORT).show() Toast.makeText(this, "No USB device connected!", Toast.LENGTH_SHORT).show()
isPatching = false return false
return
} }
for (device in usbManager.deviceList.values) { val probeTable = ProbeTable().apply {
try { addProduct(11427, 4128, CdcAcmSerialDriver::class.java)
val probeTable = ProbeTable()
probeTable.addProduct(11427, 4128, CdcAcmSerialDriver::class.java)
probeTable.addProduct(5840, 2174, CdcAcmSerialDriver::class.java)
val usbSerialProber = UsbSerialProber(probeTable) // TODO: not sure which device this is, might be the DJI remote before it's connected
val usbSerialPort = usbSerialProber.probeDevice(device).ports.firstOrNull() // to drone it seems to use a different device once connected to the drone
// addProduct(5840, 2174, CdcAcmSerialDriver::class.java)
}
if (usbSerialPort == null) { // Retrieve the custom device (DJI remote) with the correct driver from the probe table above
Toast.makeText(this, "No serial port found", Toast.LENGTH_SHORT).show() val driver = UsbSerialProber(probeTable).probeDevice(usbManager.deviceList.values.first())
return 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) try {
usbSerialPort.setParameters(19200, 8, 1, UsbSerialPort.PARITY_NONE) val deviceSerialPort = driver.ports.firstOrNull()
usbSerialPort.write( if (deviceSerialPort == null) {
byteArrayOf(85, 13, 4, 33, 42, 31, 0, 0, 0, 0, 1, -122, 32), Log.e("USB_PATCH", "Error opening USB port")
1000 Toast.makeText(this, "Error opening USB port", Toast.LENGTH_SHORT).show()
) return false
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()
} }
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( fun MainScreen(
usbConnected: Boolean, usbConnected: Boolean,
onRefresh: () -> Unit, onRefresh: () -> Unit,
onSendPatch: () -> Unit, onSendPatch: () -> Boolean,
isPatching: Boolean = false isPatching: Boolean = false
) { ) {
var buttonText by remember { mutableStateOf("Send FCC Patch") } var buttonText by remember { mutableStateOf("Send FCC Patch") }
var buttonEnabled by remember { mutableStateOf(true) } var buttonEnabled by remember { mutableStateOf(true) }
val uriHandler = LocalUriHandler.current
Scaffold( Scaffold(topBar = {
topBar = { TopAppBar(title = { Text("DJI FCC Hack") }, actions = {
TopAppBar( IconButton(onClick = onRefresh, enabled = !isPatching) {
title = { Text("DJI FCC Hack") }, Icon(Icons.Default.Refresh, contentDescription = "Refresh USB Connection")
actions = { }
IconButton(onClick = onRefresh, enabled = !isPatching) { })
Icon(Icons.Default.Refresh, contentDescription = "Refresh USB Connection") }) { innerPadding ->
}
IconButton(onClick = { /* Open Settings */ }) {
Icon(Icons.Default.MoreVert, contentDescription = "More Options")
}
}
)
}
) { innerPadding ->
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(innerPadding) .padding(innerPadding)
.padding(16.dp), .padding(16.dp)
.verticalScroll(rememberScrollState()), // Make the entire screen scrollable
verticalArrangement = Arrangement.spacedBy(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp),
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
@ -257,6 +254,61 @@ fun MainScreen(
modifier = Modifier.size(75.dp), 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 // USB Connection Status
Card( Card(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@ -285,9 +337,9 @@ fun MainScreen(
// Send Patch Button // Send Patch Button
Button( Button(
onClick = { onClick = {
buttonText = "Successfully patched"
buttonEnabled = false buttonEnabled = false
onSendPatch() val result = onSendPatch()
buttonText = if (result) "Successfully patched" else "Error patching"
CoroutineScope(Dispatchers.Main).launch { CoroutineScope(Dispatchers.Main).launch {
delay(5000) delay(5000)
@ -306,44 +358,38 @@ fun MainScreen(
Text(buttonText) Text(buttonText)
} }
// Instructions Section // Links
Card( Row {
modifier = Modifier.fillMaxWidth(), IconButton(onClick = { uriHandler.openUri(Constants.GITHUB_URL) }) {
shape = RoundedCornerShape(16.dp) Image(
) { painter = painterResource(id = isSystemInDarkTheme().let { if (it) R.drawable.github_light else R.drawable.github_dark }),
Column(modifier = Modifier.padding(16.dp)) { contentDescription = "GitHub",
Text( modifier = Modifier.size(24.dp),
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
) )
} }
} }
// Disclaimer Section // Footer
Card( Row {
modifier = Modifier.fillMaxWidth(), Text(
shape = RoundedCornerShape(16.dp), text = "Made with ❤️ by ",
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer) style = MaterialTheme.typography.bodyMedium,
) { color = MaterialTheme.colorScheme.secondary
Column(modifier = Modifier.padding(16.dp)) { )
Text( Text(
text = "Disclaimer", text = "Mathieu Broillet",
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.bodyMedium,
) fontWeight = FontWeight.Bold,
Spacer(modifier = Modifier.height(8.dp)) color = MaterialTheme.colorScheme.secondary,
Text( )
text = "This app is provided as-is and is not affiliated with DJI. Use at your own risk.",
style = MaterialTheme.typography.bodyMedium
)
}
} }
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 @Composable
fun PreviewMainScreen() { fun PreviewMainScreen() {
DJI_FCC_HACK_Theme { 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>