diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 49ec8bd..cdf81d3 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -23,16 +23,15 @@ - - - - + + + + diff --git a/app/src/main/java/ch/mathieubroillet/djiffchack/Constants.kt b/app/src/main/java/ch/mathieubroillet/djiffchack/Constants.kt index d8121e0..b015107 100644 --- a/app/src/main/java/ch/mathieubroillet/djiffchack/Constants.kt +++ b/app/src/main/java/ch/mathieubroillet/djiffchack/Constants.kt @@ -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" } \ No newline at end of file diff --git a/app/src/main/java/ch/mathieubroillet/djiffchack/MainActivity.kt b/app/src/main/java/ch/mathieubroillet/djiffchack/MainActivity.kt index de3d738..cd39e51 100644 --- a/app/src/main/java/ch/mathieubroillet/djiffchack/MainActivity.kt +++ b/app/src/main/java/ch/mathieubroillet/djiffchack/MainActivity.kt @@ -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) } } \ No newline at end of file diff --git a/app/src/main/res/drawable/github_dark.xml b/app/src/main/res/drawable/github_dark.xml new file mode 100644 index 0000000..9ce9d5d --- /dev/null +++ b/app/src/main/res/drawable/github_dark.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/src/main/res/drawable/github_light.xml b/app/src/main/res/drawable/github_light.xml new file mode 100644 index 0000000..7baa956 --- /dev/null +++ b/app/src/main/res/drawable/github_light.xml @@ -0,0 +1 @@ + \ No newline at end of file