Compare commits

..

No commits in common. "e7c34d0de5a5eb267a5e3b69448c2e542c687062" and "10aff2c404a75d2511b66a2a5ec69417490fe955" have entirely different histories.

34 changed files with 1446 additions and 1955 deletions

View File

@ -31,11 +31,6 @@ This app should work on any Android device running Android 8 and above.
* DJI Mavic Air 2 * DJI Mavic Air 2
* DJI Mini 4K * DJI Mini 4K
* DJI Mini 2 * DJI Mini 2
* DJI Air 2S
* DJI Neo 2
> [!WARNING]
> Many people reported that this hack doesn't work with the Mini 3, if anyone finds a working hack I could take a look at reverse-engineering it.
> [!NOTE] > [!NOTE]
> Please let me know if you have tested this app on another drone so I can update this README. > Please let me know if you have tested this app on another drone so I can update this README.
@ -78,7 +73,6 @@ No, this app only switches to FCC mode. To switch back to CE, turn off the drone
## Goggles Support ## Goggles Support
>[!WARNING] >[!WARNING]
> This app is not related to the following FCC file-based hacks for goggles; they are included here for reference only. > This app is not related to the following FCC file-based hacks for goggles; they are included here for reference only.
Steps to enable higher power output for DJI Goggles: Steps to enable higher power output for DJI Goggles:
**DJI Goggles V1/V2** **DJI Goggles V1/V2**

View File

@ -13,7 +13,7 @@ android {
minSdk = 26 minSdk = 26
targetSdk = 35 targetSdk = 35
versionCode = 1 versionCode = 1
versionName = "1.1" versionName = "1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }

View File

@ -28,6 +28,10 @@
<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>
<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> </activity>
</application> </application>

View File

@ -19,8 +19,6 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
@ -34,9 +32,7 @@ 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.Info
import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Star
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
@ -49,11 +45,9 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshots.SnapshotStateList
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.platform.LocalUriHandler
@ -72,44 +66,17 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
data class UsbDeviceInfo(
val device: UsbDevice,
val isDjiDevice: Boolean,
val hasPermission: Boolean
)
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
private lateinit var usbManager: UsbManager private lateinit var usbManager: UsbManager
private var usbConnected by mutableStateOf(false) private var usbConnected by mutableStateOf(false)
private var isPatching by mutableStateOf(false) private var isPatching by mutableStateOf(false)
private val debugLogs = mutableStateListOf<String>()
private val maxLogs = 50
private val usbDevices = mutableStateListOf<UsbDeviceInfo>()
private var selectedDevice by mutableStateOf<UsbDevice?>(null)
private fun addDebugLog(message: String) {
val timestamp = SimpleDateFormat("HH:mm:ss.SSS", Locale.getDefault()).format(Date())
val logEntry = "[$timestamp] $message"
debugLogs.add(0, logEntry) // Add to the beginning
Log.d("DEBUG_LOG", message)
// Keep only the most recent logs
if (debugLogs.size > maxLogs) {
debugLogs.removeAt(debugLogs.lastIndex)
}
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
usbManager = getSystemService(Context.USB_SERVICE) as UsbManager usbManager = getSystemService(Context.USB_SERVICE) as UsbManager
addDebugLog("App started")
addDebugLog("Android ${android.os.Build.VERSION.RELEASE} (API ${android.os.Build.VERSION.SDK_INT})")
// Register receiver to detect USB plug/unplug events // Register receiver to detect USB plug/unplug events
val filter = IntentFilter().apply { val filter = IntentFilter().apply {
@ -118,24 +85,15 @@ class MainActivity : ComponentActivity() {
addAction(Constants.INTENT_ACTION_GRANT_USB_PERMISSION) addAction(Constants.INTENT_ACTION_GRANT_USB_PERMISSION)
} }
ContextCompat.registerReceiver( ContextCompat.registerReceiver(
this, usbReceiver, filter, ContextCompat.RECEIVER_EXPORTED this, usbReceiver, filter, ContextCompat.RECEIVER_NOT_EXPORTED
) )
// Initial USB scan // Initial check for USB connection
refreshUsbDeviceList() refreshUsbConnection()
setContent { setContent {
DJI_FCC_HACK_Theme { DJI_FCC_HACK_Theme {
MainScreen( MainScreen(usbConnected, ::refreshUsbConnection, ::sendPatch, isPatching)
usbConnected = usbConnected,
onRefresh = ::refreshUsbDeviceList,
onSendPatch = ::sendPatch,
isPatching = isPatching,
debugLogs = debugLogs,
usbDevices = usbDevices,
selectedDevice = selectedDevice,
onDeviceSelected = ::selectDevice
)
} }
} }
} }
@ -145,90 +103,28 @@ class MainActivity : ComponentActivity() {
unregisterReceiver(usbReceiver) unregisterReceiver(usbReceiver)
} }
private fun selectDevice(device: UsbDevice) {
addDebugLog("User selected device: VID=${device.vendorId}, PID=${device.productId}")
selectedDevice = device
// Check permission status
val hasPermission = usbManager.hasPermission(device)
addDebugLog("Permission status: $hasPermission")
if (!hasPermission) {
addDebugLog("Requesting permission for selected device...")
usbConnected = false
requestUsbPermission(device)
} else {
addDebugLog("✓ Permission already granted for selected device")
usbConnected = true
}
}
/** /**
* Refreshes the USB device list * Refreshes the USB connection status
*/ */
private fun refreshUsbDeviceList() { private fun refreshUsbConnection() {
addDebugLog("=== Scanning for USB devices ===") if (usbManager.deviceList.isNotEmpty()) {
addDebugLog("Android ${android.os.Build.VERSION.RELEASE} (API ${android.os.Build.VERSION.SDK_INT})") val device: UsbDevice = usbManager.deviceList.values.first()
// Check if USB host is supported // Check to be sure the device is the initialized DJI Remote (and not another USB device)
val hasUsbHostFeature = packageManager.hasSystemFeature("android.hardware.usb.host") if (device.productId != 4128) {
addDebugLog("USB Host feature available: $hasUsbHostFeature") Log.d("USB_CONNECTION", "Device not supported ${device.productId}")
usbConnected = false
return
}
// Get device list if (usbManager.openDevice(device) == null) {
val deviceList = usbManager.deviceList Log.d("USB_CONNECTION", "Requesting USB Permission")
addDebugLog("UsbManager.deviceList size: ${deviceList.size}") requestUsbPermission(device)
} else {
if (deviceList.isEmpty()) { usbConnected = true
addDebugLog("WARNING: No USB devices found")
addDebugLog("Possible reasons:")
addDebugLog(" - No USB device connected")
addDebugLog(" - Android 16 USB enumeration bug")
addDebugLog(" - USB Protection enabled (unlock phone)")
addDebugLog(" - Try unplugging and re-plugging USB")
// Don't clear usbDevices here - keep showing last known devices
// Only clear connection status
if (selectedDevice != null) {
val stillExists = deviceList.values.any { it == selectedDevice }
if (!stillExists) {
addDebugLog("Selected device no longer in list")
usbConnected = false
selectedDevice = null
}
} }
} else { } else {
addDebugLog("Found ${deviceList.size} USB device(s):") usbConnected = false
// Build new list without clearing immediately
val newDevices = mutableListOf<UsbDeviceInfo>()
deviceList.values.forEachIndexed { index, device ->
val isDji = device.vendorId == 11427 && device.productId == 4128
val hasPerm = usbManager.hasPermission(device)
addDebugLog(" [$index] VID=0x${device.vendorId.toString(16)} PID=0x${device.productId.toString(16)}")
addDebugLog(" Name: ${device.deviceName}")
addDebugLog(" DJI Device: $isDji")
addDebugLog(" Has Permission: $hasPerm")
newDevices.add(UsbDeviceInfo(device, isDji, hasPerm))
// Update selected device permission status if it's in the new list
if (device == selectedDevice) {
usbConnected = hasPerm
addDebugLog("Selected device found, permission: $hasPerm")
}
// Auto-select DJI device if found and has permission and nothing selected
if (isDji && hasPerm && selectedDevice == null) {
selectedDevice = device
usbConnected = true
addDebugLog("✓ Auto-selected DJI device with permission")
}
}
// Update the list only after building complete list
usbDevices.clear()
usbDevices.addAll(newDevices)
} }
} }
@ -236,23 +132,12 @@ class MainActivity : ComponentActivity() {
* Sends the FCC patch to the DJI remote via USB * Sends the FCC patch to the DJI remote via USB
*/ */
private fun sendPatch(): Boolean { private fun sendPatch(): Boolean {
addDebugLog("=== Starting FCC Patch ===") // At this point, we assume the USB device is connected and we have permission to access it
if (!usbConnected) {
if (selectedDevice == null) { Toast.makeText(this, "No USB device connected!", Toast.LENGTH_SHORT).show()
addDebugLog("ERROR: No device selected")
Toast.makeText(this, "Please select a USB device first!", Toast.LENGTH_SHORT).show()
return false return false
} }
if (!usbManager.hasPermission(selectedDevice!!)) {
addDebugLog("ERROR: No permission for selected device")
Toast.makeText(this, "No USB permission!", Toast.LENGTH_SHORT).show()
return false
}
val device = selectedDevice!!
addDebugLog("Using device: VID=${device.vendorId}, PID=${device.productId}")
val probeTable = ProbeTable().apply { val probeTable = ProbeTable().apply {
addProduct(11427, 4128, CdcAcmSerialDriver::class.java) addProduct(11427, 4128, CdcAcmSerialDriver::class.java)
@ -262,41 +147,30 @@ class MainActivity : ComponentActivity() {
} }
// Retrieve the custom device (DJI remote) with the correct driver from the probe table above // Retrieve the custom device (DJI remote) with the correct driver from the probe table above
addDebugLog("Probing USB device...") val driver = UsbSerialProber(probeTable).probeDevice(usbManager.deviceList.values.first())
val driver = UsbSerialProber(probeTable).probeDevice(device)
addDebugLog("Opening USB device...")
val deviceConnection = usbManager.openDevice(driver.device) val deviceConnection = usbManager.openDevice(driver.device)
if (deviceConnection == null) { if (deviceConnection == null) {
Log.e("USB_PATCH", "Error opening USB device") Log.e("USB_PATCH", "Error opening USB device")
addDebugLog("ERROR: Failed to open USB device")
Toast.makeText(this, "Error opening USB device", Toast.LENGTH_SHORT).show() Toast.makeText(this, "Error opening USB device", Toast.LENGTH_SHORT).show()
return false return false
} }
try { try {
addDebugLog("Getting serial port...")
val deviceSerialPort = driver.ports.firstOrNull() val deviceSerialPort = driver.ports.firstOrNull()
if (deviceSerialPort == null) { if (deviceSerialPort == null) {
Log.e("USB_PATCH", "Error opening USB port") Log.e("USB_PATCH", "Error opening USB port")
addDebugLog("ERROR: No serial port found")
Toast.makeText(this, "Error opening USB port", Toast.LENGTH_SHORT).show() Toast.makeText(this, "Error opening USB port", Toast.LENGTH_SHORT).show()
return false return false
} }
addDebugLog("Opening serial port...")
deviceSerialPort.open(deviceConnection) deviceSerialPort.open(deviceConnection)
addDebugLog("Setting parameters (19200 baud, 8N1)...")
deviceSerialPort.setParameters(19200, 8, 1, UsbSerialPort.PARITY_NONE) deviceSerialPort.setParameters(19200, 8, 1, UsbSerialPort.PARITY_NONE)
addDebugLog("Writing patch bytes (part 1)...")
deviceSerialPort.write(Constants.BYTES_1, 1000) deviceSerialPort.write(Constants.BYTES_1, 1000)
addDebugLog("Writing patch bytes (part 2)...")
deviceSerialPort.write(Constants.BYTES_2, 1000) deviceSerialPort.write(Constants.BYTES_2, 1000)
addDebugLog("SUCCESS: Patch sent successfully!")
Toast.makeText(this, "Patched successfully", Toast.LENGTH_LONG).show() Toast.makeText(this, "Patched successfully", Toast.LENGTH_LONG).show()
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTrace() e.printStackTrace()
addDebugLog("ERROR: ${e.javaClass.simpleName}: ${e.message}")
Toast.makeText(this, "Error: ${e.message}", Toast.LENGTH_LONG).show() Toast.makeText(this, "Error: ${e.message}", Toast.LENGTH_LONG).show()
} }
@ -308,27 +182,14 @@ class MainActivity : ComponentActivity() {
* Requests USB permission for the device * Requests USB permission for the device
*/ */
private fun requestUsbPermission(device: UsbDevice) { private fun requestUsbPermission(device: UsbDevice) {
addDebugLog("Creating permission request intent...")
addDebugLog("Package name: $packageName")
val permissionIntent = PendingIntent.getBroadcast( val permissionIntent = PendingIntent.getBroadcast(
this, this,
0, 0,
Intent(Constants.INTENT_ACTION_GRANT_USB_PERMISSION).apply { Intent(Constants.INTENT_ACTION_GRANT_USB_PERMISSION).apply { setPackage(packageName) },
setPackage(packageName) PendingIntent.FLAG_MUTABLE
putExtra(UsbManager.EXTRA_DEVICE, device)
},
PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
) )
addDebugLog("Calling usbManager.requestPermission()...") usbManager.requestPermission(device, permissionIntent)
try {
usbManager.requestPermission(device, permissionIntent)
addDebugLog("Permission request sent successfully")
} catch (e: Exception) {
addDebugLog("ERROR requesting permission: ${e.message}")
e.printStackTrace()
}
} }
@ -340,51 +201,18 @@ class MainActivity : ComponentActivity() {
when (intent.action) { when (intent.action) {
UsbManager.ACTION_USB_DEVICE_ATTACHED -> { UsbManager.ACTION_USB_DEVICE_ATTACHED -> {
Log.d("USB_EVENT", "USB Device Connected") Log.d("USB_EVENT", "USB Device Connected")
addDebugLog("EVENT: USB device attached") refreshUsbConnection()
refreshUsbDeviceList()
} }
UsbManager.ACTION_USB_DEVICE_DETACHED -> { UsbManager.ACTION_USB_DEVICE_DETACHED -> {
Log.d("USB_EVENT", "USB Device Disconnected") Log.d("USB_EVENT", "USB Device Disconnected")
addDebugLog("EVENT: USB device detached") refreshUsbConnection()
val device = intent.getParcelableExtra(UsbManager.EXTRA_DEVICE, UsbDevice::class.java)
if (device == selectedDevice) {
addDebugLog("Selected device was detached")
selectedDevice = null
usbConnected = false
}
refreshUsbDeviceList()
} }
Constants.INTENT_ACTION_GRANT_USB_PERMISSION -> { Constants.INTENT_ACTION_GRANT_USB_PERMISSION -> {
val device = intent.getParcelableExtra(UsbManager.EXTRA_DEVICE, UsbDevice::class.java) if (intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false)) {
val granted = intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false)
addDebugLog("Permission response for device: VID=${device?.vendorId}, PID=${device?.productId}")
addDebugLog("Permission granted: $granted")
if (granted) {
Log.d("USB_EVENT", "USB Permission Granted") Log.d("USB_EVENT", "USB Permission Granted")
addDebugLog("✓ USB permission granted by user") refreshUsbConnection()
// Verify permission with hasPermission
if (device != null) {
val verified = usbManager.hasPermission(device)
addDebugLog("Permission verified with hasPermission(): $verified")
if (device == selectedDevice && verified) {
usbConnected = true
addDebugLog("✓ Selected device now connected")
}
}
// Refresh immediately
refreshUsbDeviceList()
} else {
addDebugLog("❌ USB permission denied by user")
if (device == selectedDevice) {
usbConnected = false
}
} }
} }
} }
@ -398,25 +226,14 @@ fun MainScreen(
usbConnected: Boolean, usbConnected: Boolean,
onRefresh: () -> Unit, onRefresh: () -> Unit,
onSendPatch: () -> Boolean, onSendPatch: () -> Boolean,
isPatching: Boolean = false, isPatching: Boolean = false
debugLogs: SnapshotStateList<String> = mutableStateListOf(),
usbDevices: SnapshotStateList<UsbDeviceInfo> = mutableStateListOf(),
selectedDevice: UsbDevice? = null,
onDeviceSelected: (UsbDevice) -> Unit = {}
) { ) {
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) }
var showDebugLog by remember { mutableStateOf(false) }
val uriHandler = LocalUriHandler.current val uriHandler = LocalUriHandler.current
Scaffold(topBar = { Scaffold(topBar = {
TopAppBar(title = { Text("DJI FCC Hack") }, actions = { TopAppBar(title = { Text("DJI FCC Hack") }, actions = {
androidx.compose.material3.TextButton(onClick = { showDebugLog = !showDebugLog }) {
Text(
text = if (showDebugLog) "Hide Log" else "Show Log",
style = MaterialTheme.typography.labelMedium
)
}
IconButton(onClick = onRefresh, enabled = !isPatching) { IconButton(onClick = onRefresh, enabled = !isPatching) {
Icon(Icons.Default.Refresh, contentDescription = "Refresh USB Connection") Icon(Icons.Default.Refresh, contentDescription = "Refresh USB Connection")
} }
@ -438,25 +255,25 @@ fun MainScreen(
modifier = Modifier.size(75.dp), modifier = Modifier.size(75.dp),
) )
// // Disclaimer Section // Disclaimer Section
// Card( Card(
// modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
// shape = RoundedCornerShape(16.dp), shape = RoundedCornerShape(16.dp),
// colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer) colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer)
// ) { ) {
// Column(modifier = Modifier.padding(16.dp)) { Column(modifier = Modifier.padding(16.dp)) {
// Text( Text(
// text = "Disclaimer", text = "Disclaimer",
// style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
// fontWeight = FontWeight.Bold fontWeight = FontWeight.Bold
// ) )
// Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(4.dp))
// Text( Text(
// text = "This app is provided as-is and is not affiliated with DJI. Use at your own risk.", text = "This app is provided as-is and is not affiliated with DJI. Use at your own risk.",
// style = MaterialTheme.typography.bodyMedium style = MaterialTheme.typography.bodyMedium
// ) )
// } }
// } }
// Instructions Section // Instructions Section
Card( Card(
@ -493,240 +310,28 @@ fun MainScreen(
} }
} }
// USB Device Selection Card // USB Connection Status
Card( Card(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp), shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors( colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant containerColor = if (usbConnected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.errorContainer
) )
) { ) {
Column(modifier = Modifier.padding(16.dp)) { Row(
Row( modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth() horizontalArrangement = Arrangement.Center
) {
Icon(
Icons.Default.Build,
contentDescription = "USB Devices",
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "USB Devices (${usbDevices.size})",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Spacer(modifier = Modifier.height(8.dp))
if (usbDevices.isEmpty()) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
text = "No USB devices found",
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.error
)
// Text(
// text = "Troubleshooting steps:",
// style = MaterialTheme.typography.bodySmall,
// fontWeight = FontWeight.Bold,
// color = MaterialTheme.colorScheme.onSurfaceVariant
// )
// Column(modifier = Modifier.padding(start = 8.dp)) {
// listOf(
// "Make sure phone is UNLOCKED",
// "Try unplugging and re-plugging USB",
// "Check USB notification - change to 'File Transfer' mode",
// "Enable USB debugging in Developer options",
// "Known bug in Android 16 - may not work reliably"
// ).forEach { tip ->
// Row(modifier = Modifier.padding(vertical = 2.dp)) {
// Text("• ", style = MaterialTheme.typography.bodySmall)
// Text(
// text = tip,
// style = MaterialTheme.typography.bodySmall,
// color = MaterialTheme.colorScheme.onSurfaceVariant
// )
// }
// }
// }
// Spacer(modifier = Modifier.height(4.dp))
// Text(
// text = "Tap refresh (↻) after trying each step",
// style = MaterialTheme.typography.bodySmall,
// fontStyle = FontStyle.Italic,
// color = MaterialTheme.colorScheme.primary
// )
}
} else {
usbDevices.forEach { deviceInfo ->
val isSelected = deviceInfo.device == selectedDevice
Card(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp)
.clickable { onDeviceSelected(deviceInfo.device) }
.then(
if (isSelected) {
Modifier.border(
3.dp,
MaterialTheme.colorScheme.primary,
RoundedCornerShape(8.dp)
)
} else {
Modifier
}
),
shape = RoundedCornerShape(8.dp),
colors = CardDefaults.cardColors(
containerColor = if (isSelected)
MaterialTheme.colorScheme.primaryContainer
else
MaterialTheme.colorScheme.surfaceContainerHighest
)
) {
Row(
modifier = Modifier.padding(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = "VID: 0x${deviceInfo.device.vendorId.toString(16)} / PID: 0x${deviceInfo.device.productId.toString(16)}",
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface
)
if (deviceInfo.isDjiDevice) {
Spacer(modifier = Modifier.width(8.dp))
Icon(
Icons.Default.Star,
contentDescription = "DJI Device",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(20.dp)
)
}
}
Text(
text = deviceInfo.device.deviceName,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
if (deviceInfo.isDjiDevice) {
Text(
text = "✓ DJI Remote Controller",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Bold
)
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = if (deviceInfo.hasPermission) "✓ Has Permission" else "⚠ No Permission",
style = MaterialTheme.typography.bodySmall,
color = if (deviceInfo.hasPermission)
MaterialTheme.colorScheme.primary
else
MaterialTheme.colorScheme.error
)
if (!deviceInfo.hasPermission) {
androidx.compose.material3.TextButton(
onClick = { onDeviceSelected(deviceInfo.device) },
contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 8.dp, vertical = 0.dp),
modifier = Modifier.height(24.dp)
) {
Text(
text = "Grant",
style = MaterialTheme.typography.labelSmall
)
}
}
}
}
if (isSelected) {
Icon(
Icons.Default.CheckCircle,
contentDescription = "Selected",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(24.dp)
)
}
}
}
}
}
}
}
// Debug Log Section
if (showDebugLog) {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) { ) {
Column(modifier = Modifier.padding(16.dp)) { Icon(
Row( if (usbConnected) Icons.Default.CheckCircle else Icons.Default.Clear,
verticalAlignment = Alignment.CenterVertically, contentDescription = "USB Status"
modifier = Modifier.fillMaxWidth() )
) { Spacer(Modifier.width(8.dp))
Icon( Text(
Icons.Default.Info, text = if (usbConnected) "Remote Connected" else "Remote Not Connected",
contentDescription = "Debug Info", style = MaterialTheme.typography.bodyMedium
tint = MaterialTheme.colorScheme.primary )
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "Debug Log",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
}
Spacer(modifier = Modifier.height(8.dp))
if (debugLogs.isEmpty()) {
Text(
text = "No logs yet...",
style = MaterialTheme.typography.bodySmall,
fontStyle = FontStyle.Italic,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
} else {
Card(
modifier = Modifier
.fillMaxWidth()
.height(200.dp),
shape = RoundedCornerShape(8.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface
)
) {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(8.dp)
) {
debugLogs.forEach { log ->
Text(
text = log,
style = MaterialTheme.typography.bodySmall,
fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace,
modifier = Modifier.padding(vertical = 2.dp)
)
}
}
}
}
}
} }
} }
@ -747,22 +352,13 @@ fun MainScreen(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(56.dp), .height(56.dp),
enabled = selectedDevice != null && !isPatching && buttonEnabled enabled = usbConnected && !isPatching && buttonEnabled
) { ) {
Icon(Icons.Default.Build, contentDescription = "Patch") Icon(Icons.Default.Build, contentDescription = "Patch")
Spacer(Modifier.width(8.dp)) Spacer(Modifier.width(8.dp))
Text(buttonText) Text(buttonText)
} }
if (selectedDevice == null && usbDevices.isNotEmpty()) {
Text(
text = "Please select a USB device above",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
fontStyle = FontStyle.Italic
)
}
// Links // Links
Row { Row {
IconButton(onClick = { uriHandler.openUri(Constants.GITHUB_URL) }) { IconButton(onClick = { uriHandler.openUri(Constants.GITHUB_URL) }) {
@ -787,17 +383,6 @@ fun MainScreen(
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.secondary, color = MaterialTheme.colorScheme.secondary,
) )
Text(
text = " updated by ",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.secondary
)
Text(
text = "luhf",
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.secondary,
)
} }
Text( Text(

View File

@ -1,84 +0,0 @@
# DJI FCC Patch Script for Windows
#
# This script sends a "magic packet" to a DJI controller
# to enable FCC mode, which can increase the drone's signal range.
#
# Credits:
# - Based on the Android app by Mathieu Broillet: https://github.com/M4TH1EU/DJI-FCC-HACK
# - Original research by @galbb on MavicPilots.com
#
# HOW TO USE:
# 1. Install Python from the Microsoft Store or python.org.
# 2. Install the pyserial library by opening a Command Prompt or PowerShell and running:
# pip install pyserial
# 3. Turn on your drone and controller, and wait for them to connect.
# 4. Connect your PC to the bottom USB port of the controller.
# 5. Run this script.
# 6. If successful, disconnect the PC and connect your phone to the top USB port of the controller.
#
# DISCLAIMER: Use at your own risk. This script modifies your device's operation.
# The authors are not responsible for any damage or legal issues that may arise.
import serial
import serial.tools.list_ports
import time
# DJI Controller USB identifiers
VENDOR_ID = 11427
PRODUCT_ID = 4128
# The "magic bytes" that enable FCC mode
# (from ch.mathieubroillet.djiffchack.Constants.kt)
BYTES_1 = bytes([85, 13, 4, 33, 42, 31, 0, 0, 0, 0, 1, -122 & 0xFF, 32])
BYTES_2 = bytes([85, 24, 4, 32, 2, 9, 0, 0, 64, 9, 39, 0, 2, 72, 0, -1 & 0xFF, -1 & 0xFF, 2, 0, 0, 0, 0, -127 & 0xFF, 31])
def find_dji_controller():
"""Finds the COM port of the connected DJI controller."""
ports = serial.tools.list_ports.comports()
for port in ports:
if port.vid == VENDOR_ID and port.pid == PRODUCT_ID:
print(f"Found DJI Controller at {port.device}")
return port.device
return None
def send_patch(com_port):
"""Opens the serial port and sends the patch bytes."""
try:
with serial.Serial(com_port, baudrate=19200, bytesize=8, parity='N', stopbits=1, timeout=1) as ser:
print("Sending patch...")
# Send first byte array
ser.write(BYTES_1)
print(f"Sent {len(BYTES_1)} bytes (Packet 1).")
time.sleep(0.1) # Small delay between writes
# Send second byte array
ser.write(BYTES_2)
print(f"Sent {len(BYTES_2)} bytes (Packet 2).")
print("\nPatch sent successfully!")
print("You can now disconnect the controller from your PC.")
return True
except serial.SerialException as e:
print(f"Error: Could not open or write to serial port {com_port}.")
print(f"Details: {e}")
print("\nPlease ensure the controller is properly connected and no other software is using the COM port.")
return False
if __name__ == "__main__":
print("--- DJI FCC Patch Script ---")
print("Searching for DJI Controller...")
controller_port = find_dji_controller()
if controller_port:
send_patch(controller_port)
else:
print("\nDJI Controller not found.")
print("Please make sure:")
print("1. The controller is turned on and connected to the drone.")
print("2. Your PC is connected to the BOTTOM USB port of the controller.")
print("3. The necessary USB drivers are installed.")
print("\nPress Enter to exit.")
input()

View File

@ -1,5 +1,5 @@
[versions] [versions]
agp = "8.11.0" agp = "8.8.0"
kotlin = "2.0.0" kotlin = "2.0.0"
coreKtx = "1.10.1" coreKtx = "1.10.1"
junit = "4.13.2" junit = "4.13.2"
@ -27,7 +27,7 @@ androidx-material3 = { group = "androidx.compose.material3", name = "material3"
[plugins] [plugins]
android-application = { id = "com.android.application", version = "8.6.0" } android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }

View File

@ -1,7 +0,0 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-all.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

3
gradlew vendored
View File

@ -174,8 +174,7 @@ fi
# Escape application args # Escape application args
save () { save () {
for i do printf %s\\n "$i" | sed "s/'/'\\''/g;1s/^/'/; for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
" ; done
echo " " echo " "
} }
APP_ARGS=`save "$@"` APP_ARGS=`save "$@"`