增加文件传输功能

This commit is contained in:
huanglinhuan
2025-12-22 14:49:47 +08:00
parent 065251f727
commit e3db1b57a0
13 changed files with 1014 additions and 11 deletions

View File

@@ -5,6 +5,7 @@
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="28" />
<application
android:allowBackup="true"

View File

@@ -4,13 +4,19 @@
#define LOG_TAG "ReceiverEngine"
#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__)
ReceiverEngine::ReceiverEngine(JNIEnv* env, jobject surface) {
ReceiverEngine::ReceiverEngine(JNIEnv* env, jobject surface, jobject callbackObj) {
env->GetJavaVM(&jvm_);
callbackObj_ = env->NewGlobalRef(callbackObj);
window_ = ANativeWindow_fromSurface(env, surface);
// Bind callback
receiver_.SetCallback([this](const std::vector<uint8_t>& data, const FrameHeader& header) {
this->OnFrameReceived(data, header);
});
receiver_.SetSenderDetectedCallback([this](const std::string& ip, int port) {
this->NotifyJavaSenderDetected(ip, port);
});
}
ReceiverEngine::~ReceiverEngine() {
@@ -19,8 +25,43 @@ ReceiverEngine::~ReceiverEngine() {
ANativeWindow_release(window_);
window_ = nullptr;
}
if (callbackObj_ && jvm_) {
JNIEnv* env = nullptr;
if (jvm_->GetEnv((void**)&env, JNI_VERSION_1_6) == JNI_OK) {
env->DeleteGlobalRef(callbackObj_);
} else {
// If thread not attached, we leak? Or attach to delete?
// Usually destructor called on main thread or known thread.
}
}
}
void ReceiverEngine::NotifyJavaSenderDetected(const std::string& ip, int port) {
if (!jvm_ || !callbackObj_) return;
JNIEnv* env = nullptr;
bool needsDetach = false;
int res = jvm_->GetEnv((void**)&env, JNI_VERSION_1_6);
if (res == JNI_EDETACHED) {
if (jvm_->AttachCurrentThread(&env, nullptr) != JNI_OK) return;
needsDetach = true;
}
jclass clazz = env->GetObjectClass(callbackObj_);
jmethodID methodId = env->GetMethodID(clazz, "onSenderDetected", "(Ljava/lang/String;I)V");
if (methodId) {
jstring jIp = env->NewStringUTF(ip.c_str());
env->CallVoidMethod(callbackObj_, methodId, jIp, port);
env->DeleteLocalRef(jIp);
}
if (needsDetach) {
jvm_->DetachCurrentThread();
}
}
void ReceiverEngine::Start(int port) {
receiver_.Start(port);
}

View File

@@ -8,17 +8,25 @@
class ReceiverEngine {
public:
ReceiverEngine(JNIEnv* env, jobject surface);
ReceiverEngine(JNIEnv* env, jobject surface, jobject callbackObj);
~ReceiverEngine();
void Start(int port);
void Stop();
// Callback to JNI
void SetSenderDetectedCallback(std::function<void(const std::string&, int)> cb);
private:
void OnFrameReceived(const std::vector<uint8_t>& data, const FrameHeader& header);
void NotifyJavaSenderDetected(const std::string& ip, int port);
ANativeWindow* window_ = nullptr;
UdpReceiver receiver_;
VideoDecoder decoder_;
bool decoder_initialized_ = false;
std::function<void(const std::string&, int)> sender_cb_;
JavaVM* jvm_ = nullptr;
jobject callbackObj_ = nullptr;
};

View File

@@ -1,6 +1,7 @@
#include "UdpReceiver.h"
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <android/log.h>
#include <cstring>
@@ -56,17 +57,36 @@ void UdpReceiver::SetCallback(OnFrameReceivedCallback callback) {
callback_ = callback;
}
void UdpReceiver::SetSenderDetectedCallback(OnSenderDetectedCallback callback) {
sender_callback_ = callback;
}
void UdpReceiver::ReceiveLoop() {
std::vector<uint8_t> buffer(65535);
LOGI("Receiver thread started");
while (running_) {
ssize_t received = recvfrom(sockfd_, buffer.data(), buffer.size(), 0, nullptr, nullptr);
struct sockaddr_in senderAddr;
socklen_t senderLen = sizeof(senderAddr);
ssize_t received = recvfrom(sockfd_, buffer.data(), buffer.size(), 0, (struct sockaddr*)&senderAddr, &senderLen);
if (received <= 0) {
if (running_) LOGE("Recv failed or socket closed");
continue;
}
// Check sender IP
char ipStr[INET_ADDRSTRLEN];
inet_ntop(AF_INET, &(senderAddr.sin_addr), ipStr, INET_ADDRSTRLEN);
std::string senderIp(ipStr);
if (senderIp != last_sender_ip_) {
last_sender_ip_ = senderIp;
LOGI("New sender detected: %s", senderIp.c_str());
if (sender_callback_) {
sender_callback_(senderIp, 8889); // Assume TCP port 8889
}
}
if (received < 8) continue; // Header size check
// Parse Packet Header

View File

@@ -17,6 +17,7 @@ struct FrameHeader {
// Callback for full frames
using OnFrameReceivedCallback = std::function<void(const std::vector<uint8_t>& frameData, const FrameHeader& header)>;
using OnSenderDetectedCallback = std::function<void(const std::string& ip, int port)>;
class UdpReceiver {
public:
@@ -26,6 +27,7 @@ public:
bool Start(int port);
void Stop();
void SetCallback(OnFrameReceivedCallback callback);
void SetSenderDetectedCallback(OnSenderDetectedCallback callback);
private:
void ReceiveLoop();
@@ -34,6 +36,8 @@ private:
std::atomic<bool> running_{false};
std::thread worker_thread_;
OnFrameReceivedCallback callback_;
OnSenderDetectedCallback sender_callback_;
std::string last_sender_ip_;
// Fragmentation handling
struct Fragment {

View File

@@ -5,9 +5,9 @@
extern "C" JNIEXPORT jlong JNICALL
Java_com_displayflow_receiver_MainActivity_nativeInit(
JNIEnv* env,
jobject /* this */,
jobject thiz,
jobject surface) {
auto engine = new ReceiverEngine(env, surface);
auto engine = new ReceiverEngine(env, surface, thiz);
return reinterpret_cast<jlong>(engine);
}

View File

@@ -7,35 +7,65 @@ import android.view.SurfaceHolder
import android.view.SurfaceView
import android.view.WindowManager
import android.widget.FrameLayout
import android.content.Intent
import android.net.Uri
import android.util.Log
import android.view.Gravity
import android.graphics.Color
import android.widget.Button
class MainActivity : AppCompatActivity(), SurfaceHolder.Callback {
private lateinit var surfaceView: SurfaceView
private var nativeEngine: Long = 0
private lateinit var fileTransfer: TcpFileTransfer
private val REQUEST_CODE_PICK_FILE = 1001
private val REQUEST_CODE_PICK_FOLDER = 1002
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Keep screen on
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
// Create layout programmatically
val layout = FrameLayout(this)
surfaceView = SurfaceView(this)
layout.addView(surfaceView)
val btn = Button(this)
btn.text = "发送文件夹"
btn.textSize = 12f
btn.setTextColor(Color.WHITE)
btn.setBackgroundColor(0x66000000)
btn.alpha = 0.8f
btn.setPadding(24, 12, 24, 12)
val margin = (16 * resources.displayMetrics.density).toInt()
val lp = FrameLayout.LayoutParams(
FrameLayout.LayoutParams.WRAP_CONTENT,
FrameLayout.LayoutParams.WRAP_CONTENT,
Gravity.END or Gravity.BOTTOM
)
lp.setMargins(margin, margin, margin, margin)
layout.addView(btn, lp)
setContentView(layout)
surfaceView.holder.addCallback(this)
fileTransfer = TcpFileTransfer(this)
surfaceView.setOnLongClickListener {
pickFileToSend()
true
}
btn.setOnClickListener {
pickFolderToSend()
}
}
override fun surfaceCreated(holder: SurfaceHolder) {
// Init native engine with surface
nativeEngine = nativeInit(holder.surface)
nativeStart(nativeEngine, 8888) // Listen on port 8888
nativeStart(nativeEngine, 8888)
}
override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {
// Handle resize if needed
}
override fun surfaceDestroyed(holder: SurfaceHolder) {
@@ -46,6 +76,38 @@ class MainActivity : AppCompatActivity(), SurfaceHolder.Callback {
}
}
fun onSenderDetected(ip: String, port: Int) {
Log.i("MainActivity", "Sender detected: $ip:$port")
fileTransfer.connect(ip, port)
}
private fun pickFileToSend() {
val intent = Intent(Intent.ACTION_GET_CONTENT)
intent.addCategory(Intent.CATEGORY_OPENABLE)
intent.type = "*/*"
startActivityForResult(Intent.createChooser(intent, "选择要发送的文件"), REQUEST_CODE_PICK_FILE)
}
private fun pickFolderToSend() {
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
startActivityForResult(intent, REQUEST_CODE_PICK_FOLDER)
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if (requestCode == REQUEST_CODE_PICK_FILE && resultCode == RESULT_OK) {
val uri: Uri? = data?.data
if (uri != null) {
fileTransfer.sendFile(uri)
}
}
if (requestCode == REQUEST_CODE_PICK_FOLDER && resultCode == RESULT_OK) {
val uri: Uri? = data?.data
if (uri != null) {
fileTransfer.sendFolder(uri)
}
}
}
// Native methods
external fun nativeInit(surface: Surface): Long
external fun nativeStart(enginePtr: Long, port: Int)

View File

@@ -0,0 +1,301 @@
package com.displayflow.receiver
import android.content.Context
import android.content.ContentValues
import android.net.Uri
import android.os.Environment
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.widget.Toast
import android.provider.MediaStore
import androidx.documentfile.provider.DocumentFile
import java.io.DataInputStream
import java.io.DataOutputStream
import java.io.File
import java.io.OutputStream
import java.net.Socket
import java.nio.charset.StandardCharsets
class TcpFileTransfer(private val context: Context) {
private var socket: Socket? = null
private var dis: DataInputStream? = null
private var dos: DataOutputStream? = null
private var isConnected = false
private val mainHandler = Handler(Looper.getMainLooper())
fun connect(ip: String, port: Int) {
Thread {
try {
if (isConnected) return@Thread
Log.i("TcpFileTransfer", "Connecting to $ip:$port")
socket = Socket(ip, port)
dis = DataInputStream(socket!!.getInputStream())
dos = DataOutputStream(socket!!.getOutputStream())
isConnected = true
Log.i("TcpFileTransfer", "Connected")
// Start receiving loop
receiveLoop()
} catch (e: Exception) {
Log.e("TcpFileTransfer", "Connection failed", e)
}
}.start()
}
private fun receiveLoop() {
try {
while (isConnected) {
val type = dis!!.readUnsignedByte()
val sizeBytes = ByteArray(4)
dis!!.readFully(sizeBytes)
val realSize = java.nio.ByteBuffer.wrap(sizeBytes).order(java.nio.ByteOrder.LITTLE_ENDIAN).int
val payload = ByteArray(realSize)
if (realSize > 0) {
dis!!.readFully(payload)
}
when (type) {
0x10 -> handleFileHeader(payload)
0x11 -> handleFileData(payload)
0x12 -> handleFileEnd()
0x20 -> handleFolderHeader(payload)
0x21 -> handleDirEntry(payload)
0x22 -> handleFileHeaderV2(payload)
0x23 -> { folderRootName = null }
}
}
} catch (e: Exception) {
Log.e("TcpFileTransfer", "Receive loop error", e)
disconnect()
}
}
private var currentFileOutputStream: OutputStream? = null
private var currentFileName: String = ""
private var currentFileSize: Long = 0
private var currentFilePath: File? = null
private var currentFileUri: Uri? = null
private var folderRootName: String? = null
private fun handleFileHeader(payload: ByteArray) {
val buffer = java.nio.ByteBuffer.wrap(payload)
buffer.order(java.nio.ByteOrder.LITTLE_ENDIAN)
currentFileSize = buffer.long
val nameBytes = ByteArray(256)
buffer.get(nameBytes)
val nameString = String(nameBytes, StandardCharsets.UTF_8).trim { it <= ' ' }
currentFileName = nameString.substringBefore('\u0000')
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val values = ContentValues().apply {
put(MediaStore.Downloads.DISPLAY_NAME, currentFileName)
put(MediaStore.Downloads.MIME_TYPE, "application/octet-stream")
put(MediaStore.Downloads.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS)
}
val uri = context.contentResolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, values)
if (uri != null) {
currentFileOutputStream = context.contentResolver.openOutputStream(uri)
currentFileUri = uri
Log.i("TcpFileTransfer", "Receiving file to public Downloads (MediaStore): $currentFileName ($currentFileSize bytes)")
} else {
Log.e("TcpFileTransfer", "Failed to create MediaStore entry for $currentFileName")
}
} else {
val downloadsDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
val file = File(downloadsDir, currentFileName)
Log.i("TcpFileTransfer", "Receiving file to public Downloads: ${file.absolutePath} ($currentFileSize bytes)")
currentFileOutputStream = file.outputStream()
currentFilePath = file
}
}
private fun handleFileData(payload: ByteArray) {
currentFileOutputStream?.write(payload)
}
private fun handleFileEnd() {
currentFileOutputStream?.close()
currentFileOutputStream = null
Log.i("TcpFileTransfer", "File received successfully")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
currentFileUri?.let {
mainHandler.post { Toast.makeText(context, "已保存到 下载: $currentFileName", Toast.LENGTH_LONG).show() }
}
} else {
currentFilePath?.let {
mainHandler.post { Toast.makeText(context, "已保存: ${it.absolutePath}", Toast.LENGTH_LONG).show() }
}
}
}
private fun handleFolderHeader(payload: ByteArray) {
val name = String(payload, StandardCharsets.UTF_8).substringBefore('\u0000')
folderRootName = if (name.isNotBlank()) name else "AndroidFolder"
}
private fun handleDirEntry(payload: ByteArray) {
val rel = String(payload, StandardCharsets.UTF_8).substringBefore('\u0000').replace('\\', '/')
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
val base = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
val dir = File(base, (folderRootName ?: "AndroidFolder") + "/" + rel)
dir.mkdirs()
}
}
private fun handleFileHeaderV2(payload: ByteArray) {
val buf = java.nio.ByteBuffer.wrap(payload).order(java.nio.ByteOrder.LITTLE_ENDIAN)
val size = buf.long
val pathLen = buf.short.toInt() and 0xFFFF
val pathBytes = ByteArray(pathLen)
buf.get(pathBytes)
val rel = String(pathBytes, StandardCharsets.UTF_8).replace('\\', '/')
currentFileSize = size
val lastSlash = rel.lastIndexOf('/')
val parent = if (lastSlash >= 0) rel.substring(0, lastSlash) else ""
val fileName = if (lastSlash >= 0) rel.substring(lastSlash + 1) else rel
currentFileName = fileName
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val relPath = if (parent.isNotBlank()) (Environment.DIRECTORY_DOWNLOADS + "/" + (folderRootName ?: "AndroidFolder") + "/" + parent)
else (Environment.DIRECTORY_DOWNLOADS + "/" + (folderRootName ?: "AndroidFolder"))
val values = ContentValues().apply {
put(MediaStore.Downloads.DISPLAY_NAME, currentFileName)
put(MediaStore.Downloads.MIME_TYPE, "application/octet-stream")
put(MediaStore.Downloads.RELATIVE_PATH, relPath)
}
val uri = context.contentResolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, values)
if (uri != null) {
currentFileOutputStream = context.contentResolver.openOutputStream(uri)
currentFileUri = uri
} else {
currentFileOutputStream = null
}
} else {
val base = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
val dir = File(base, (folderRootName ?: "AndroidFolder") + (if (parent.isNotBlank()) "/$parent" else ""))
dir.mkdirs()
val f = File(dir, currentFileName)
currentFileOutputStream = f.outputStream()
currentFilePath = f
}
}
fun sendFile(uri: Uri) {
Thread {
try {
if (!isConnected || dos == null) {
Log.e("TcpFileTransfer", "Not connected")
return@Thread
}
val cursor = context.contentResolver.query(uri, null, null, null, null)
val nameIndex = cursor?.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME)
val sizeIndex = cursor?.getColumnIndex(android.provider.OpenableColumns.SIZE)
cursor?.moveToFirst()
val fileName = cursor?.getString(nameIndex ?: 0) ?: "unknown_file"
val fileSize = cursor?.getLong(sizeIndex ?: 0) ?: 0L
cursor?.close()
val inputStream = context.contentResolver.openInputStream(uri) ?: return@Thread
dos!!.writeByte(0x10)
dos!!.writeInt(Integer.reverseBytes(264))
val buffer = java.nio.ByteBuffer.allocate(264)
buffer.order(java.nio.ByteOrder.LITTLE_ENDIAN)
buffer.putLong(fileSize)
val nameBytes = fileName.toByteArray(StandardCharsets.UTF_8)
val finalNameBytes = ByteArray(256)
System.arraycopy(nameBytes, 0, finalNameBytes, 0, Math.min(nameBytes.size, 256))
buffer.put(finalNameBytes)
dos!!.write(buffer.array())
val chunk = ByteArray(64 * 1024)
var bytesRead: Int
while (inputStream.read(chunk).also { bytesRead = it } != -1) {
dos!!.writeByte(0x11)
dos!!.writeInt(Integer.reverseBytes(bytesRead))
dos!!.write(chunk, 0, bytesRead)
}
inputStream.close()
dos!!.writeByte(0x12)
dos!!.writeInt(0)
Log.i("TcpFileTransfer", "File sent successfully")
} catch (e: Exception) {
Log.e("TcpFileTransfer", "Send file failed", e)
}
}.start()
}
fun sendFolder(uri: Uri) {
Thread {
try {
if (!isConnected || dos == null) {
Log.e("TcpFileTransfer", "Not connected")
return@Thread
}
val root = DocumentFile.fromTreeUri(context, uri) ?: return@Thread
val rootName = root.name ?: "AndroidFolder"
val nameBytes = rootName.toByteArray(StandardCharsets.UTF_8)
dos!!.writeByte(0x20)
dos!!.writeInt(Integer.reverseBytes(nameBytes.size))
dos!!.write(nameBytes)
fun sendEntry(doc: DocumentFile, base: String) {
val rel = if (base.isEmpty()) (doc.name ?: "") else (base + "/" + (doc.name ?: ""))
if (doc.isDirectory) {
val pathBytes = rel.toByteArray(StandardCharsets.UTF_8)
dos!!.writeByte(0x21)
dos!!.writeInt(Integer.reverseBytes(pathBytes.size))
dos!!.write(pathBytes)
doc.listFiles().forEach { child ->
sendEntry(child, rel)
}
} else {
val size = doc.length()
val pathBytes = rel.toByteArray(StandardCharsets.UTF_8)
val headerSize = 8 + 2 + pathBytes.size
dos!!.writeByte(0x22)
dos!!.writeInt(Integer.reverseBytes(headerSize))
val bb = java.nio.ByteBuffer.allocate(headerSize)
bb.order(java.nio.ByteOrder.LITTLE_ENDIAN)
bb.putLong(size)
bb.putShort(pathBytes.size.toShort())
bb.put(pathBytes)
dos!!.write(bb.array())
val input = context.contentResolver.openInputStream(doc.uri)
if (input != null) {
val chunk = ByteArray(64 * 1024)
var bytesRead: Int
while (input.read(chunk).also { bytesRead = it } != -1) {
dos!!.writeByte(0x11)
dos!!.writeInt(Integer.reverseBytes(bytesRead))
dos!!.write(chunk, 0, bytesRead)
}
input.close()
}
dos!!.writeByte(0x12)
dos!!.writeInt(0)
}
}
root.listFiles().forEach { sendEntry(it, "") }
dos!!.writeByte(0x23)
dos!!.writeInt(0)
Log.i("TcpFileTransfer", "Folder sent successfully")
} catch (e: Exception) {
Log.e("TcpFileTransfer", "Send folder failed", e)
}
}.start()
}
fun disconnect() {
isConnected = false
try {
socket?.close()
} catch (e: Exception) {}
}
}