mirror of
https://github.com/kunkundi/crossdesk.git
synced 2026-10-18 09:53:05 +08:00
[feat] complete mobile session controls and file transfer
This commit is contained in:
@@ -4,6 +4,7 @@ import android.app.Activity;
|
||||
import android.app.AlertDialog;
|
||||
import android.app.Dialog;
|
||||
import android.content.ClipData;
|
||||
import android.content.Intent;
|
||||
import android.content.ClipboardManager;
|
||||
import android.content.SharedPreferences;
|
||||
import android.content.pm.ActivityInfo;
|
||||
@@ -75,6 +76,11 @@ public final class MainActivity extends Activity implements NativeSession.Listen
|
||||
private int port,selectedDisplay;
|
||||
private JSONArray displays=new JSONArray();
|
||||
private boolean muted,ready,rememberPassword;
|
||||
private boolean remoteServiceAvailable;
|
||||
private static final int PICK_FILE=41,SAVE_FILE=42;
|
||||
private NativeSession documentSession;
|
||||
private java.io.File receivedFile,documentToSave;
|
||||
private final Runnable documentTimeout=()->{if(documentSession!=null&&!foreground)end("文件选择已超时,会话已结束");};
|
||||
private RemoteVideoSettings videoSettings=new RemoteVideoSettings(1);
|
||||
private final android.os.Handler mainHandler=new android.os.Handler(android.os.Looper.getMainLooper());
|
||||
private Runnable videoSettingsTimeout;
|
||||
@@ -277,6 +283,7 @@ public final class MainActivity extends Activity implements NativeSession.Listen
|
||||
window.setGravity(width>=600?Gravity.CENTER:Gravity.BOTTOM);
|
||||
}
|
||||
private void begin(String id,String secret){
|
||||
remoteServiceAvailable=false;receivedFile=null;
|
||||
resetRemoteVersionCheck();
|
||||
resetVideoSettings();
|
||||
previewCapture=previews.begin(serverKey(),id);previewCopying=false;previewAttempts=0;
|
||||
@@ -294,6 +301,7 @@ public final class MainActivity extends Activity implements NativeSession.Listen
|
||||
installRoot("session",true);setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_FULL_SENSOR);video=new RemoteVideoView(this,session);video.setRelative(preferences.getBoolean("relativeMouse",true));canvas.addView(video,0,new FrameLayout.LayoutParams(-1,-1));
|
||||
controls=new SessionControls(this,ui,new SessionControls.Actions(){
|
||||
public void disconnect(){confirmDisconnect();}public void display(){selectDisplay();}public void clipboard(){MainActivity.this.clipboard();}
|
||||
public void sendFile(){chooseFile();}public void saveFile(){saveReceivedFile();}public void secureAttention(){MainActivity.this.secureAttention();}
|
||||
public void key(int code){tapKey(code);}
|
||||
public void chord(int code,int[] modifiers){if(session==null)return;for(int modifier:modifiers)session.key(modifier,true);tapKey(code);for(int i=modifiers.length-1;i>=0;i--)session.key(modifiers[i],false);}public void shortcut(int code){MainActivity.this.shortcut(code);}public void text(String value){sendText(value);}
|
||||
public void mute(){muted=!muted;if(session!=null)session.control(2,muted?0:1);controls.setMuted(muted);}
|
||||
@@ -303,6 +311,7 @@ public final class MainActivity extends Activity implements NativeSession.Listen
|
||||
}
|
||||
private void confirmDisconnect(){disconnectDialog=new AlertDialog.Builder(this).setTitle("断开远程连接?").setMessage("断开后将返回首页").setNegativeButton("取消",null).setPositiveButton("断开连接",(d,w)->end("")).show();}
|
||||
private void end(String reason){
|
||||
mainHandler.removeCallbacks(documentTimeout);documentSession=null;documentToSave=null;receivedFile=null;remoteServiceAvailable=false;
|
||||
resetRemoteVersionCheck();
|
||||
resetVideoSettings();
|
||||
previewCapture=null;previewCopying=false;
|
||||
@@ -432,6 +441,44 @@ public final class MainActivity extends Activity implements NativeSession.Listen
|
||||
private void selectDisplay(){
|
||||
if(!ready)return;int count=Math.min(displays.length(),8);if(count==0){toast("等待远端屏幕信息");return;}String[] labels=new String[count];for(int i=0;i<count;i++){JSONObject display=displays.optJSONObject(i);labels[i]=(i+1)+" · "+(display==null?"显示器":display.optString("name","显示器"));}new AlertDialog.Builder(this).setTitle("显示器").setItems(labels,(d,w)->{if(session!=null){if(w!=selectedDisplay&&video!=null)video.resetViewport();selectedDisplay=w;session.control(4,w);if(controls!=null)controls.resetVideoStatistics();}}).show();
|
||||
}
|
||||
private void secureAttention(){
|
||||
if(!ready||session==null)return;
|
||||
if(!remoteServiceAvailable){controlFeedback(hostPlatform.equals("windows")?"请在远端启用 CrossDesk 系统服务后重试":"Ctrl+Alt+Del 需要远端 Windows 系统服务支持");return;}
|
||||
session.secureAttention();
|
||||
}
|
||||
private void chooseFile(){
|
||||
if(!ready||session==null)return;
|
||||
openDocument(new Intent(Intent.ACTION_OPEN_DOCUMENT).addCategory(Intent.CATEGORY_OPENABLE).setType("*/*"),PICK_FILE);
|
||||
}
|
||||
private void saveReceivedFile(){
|
||||
if(!ready||session==null||receivedFile==null)return;
|
||||
documentToSave=receivedFile;
|
||||
openDocument(new Intent(Intent.ACTION_CREATE_DOCUMENT).addCategory(Intent.CATEGORY_OPENABLE).setType("application/octet-stream").putExtra(Intent.EXTRA_TITLE,receivedFile.getName()),SAVE_FILE);
|
||||
}
|
||||
private void openDocument(Intent intent,int request){
|
||||
if(documentSession!=null)return;
|
||||
documentSession=session;releaseKeys();
|
||||
try{startActivityForResult(intent,request);mainHandler.postDelayed(documentTimeout,5*60*1000);}
|
||||
catch(android.content.ActivityNotFoundException error){documentSession=null;documentToSave=null;controlFeedback("此设备没有可用的文件选择器");}
|
||||
}
|
||||
@Override protected void onActivityResult(int request,int result,Intent data){
|
||||
super.onActivityResult(request,result,data);
|
||||
if(request!=PICK_FILE&&request!=SAVE_FILE)return;
|
||||
NativeSession source=documentSession;java.io.File saved=documentToSave;
|
||||
documentSession=null;documentToSave=null;mainHandler.removeCallbacks(documentTimeout);
|
||||
if(source==null||source!=session||!ready||result!=RESULT_OK||data==null||data.getData()==null)return;
|
||||
if(request==PICK_FILE)source.sendFile(data.getData());else if(saved!=null)source.saveFile(saved,data.getData());
|
||||
}
|
||||
@Override public void fileTransfer(String name,double progress,boolean sending,java.io.File received){
|
||||
if(!ready)return;
|
||||
if(received!=null)receivedFile=received;
|
||||
String fileStatus=progress<0?name+" 传输失败":(sending?"发送 ":"接收 ")+name+(progress>=1?" · 已完成":" · "+(int)(progress*100)+"%");
|
||||
if(controls!=null)controls.fileStatus(fileStatus,receivedFile!=null);
|
||||
if(progress<0||progress>=1)toast(fileStatus);
|
||||
}
|
||||
@Override public void controlFeedback(String message){
|
||||
toast(message);
|
||||
}
|
||||
private void clipboard(){
|
||||
if(!ready)return;
|
||||
EditText input=field("要发送到远端的文本",InputType.TYPE_CLASS_TEXT|InputType.TYPE_TEXT_FLAG_MULTI_LINE);input.setSingleLine(false);input.setMinLines(3);
|
||||
@@ -540,6 +587,7 @@ public final class MainActivity extends Activity implements NativeSession.Listen
|
||||
}
|
||||
@Override public void data(JSONObject message){
|
||||
if(session==null)return;
|
||||
if(message.optInt("type",-1)==5){JSONObject service=message.optJSONObject("service_status");remoteServiceAvailable=service!=null&&service.optBoolean("available",false);return;}
|
||||
if(message.optInt("type",-1)==12){if(!ready)return;videoSettings.receive(message);if(!videoSettings.pending)cancelVideoSettingsTimeout();if(controls!=null)controls.videoSettings(videoSettings);return;}
|
||||
JSONObject info=message.optJSONObject("host_info");
|
||||
if(info!=null){
|
||||
@@ -572,6 +620,9 @@ public final class MainActivity extends Activity implements NativeSession.Listen
|
||||
}
|
||||
// onStart marks a new visit; focus/resume callbacks must not reopen a refused notice.
|
||||
@Override protected void onStart(){super.onStart();foreground=true;appUpdates.setEnabled(preferences.getBoolean("networkConsent",false));if(!preferences.getBoolean("networkConsent",false))consent(this::renderSignaling);ensureSignaling();}
|
||||
@Override protected void onStop(){foreground=false;appUpdates.setEnabled(false);if(privacyDialog!=null)privacyDialog.dismiss();pageHost.finish();if(session!=null)end(isFinishing()?"":"应用已进入后台,会话已结束");stopSignaling();super.onStop();}
|
||||
@Override protected void onDestroy(){appUpdates.setEnabled(false);pageHost.finish();resetRemoteVersionCheck();cancelVideoSettingsTimeout();previews.removeListener(previewChanged);if(privacyDialog!=null)privacyDialog.dismiss();if(previewDialog!=null)previewDialog.dismiss();announcementInbox.close();if(disconnectDialog!=null)disconnectDialog.dismiss();if(passwordDialog!=null)passwordDialog.dismiss();if(progressDialog!=null)progressDialog.dismiss();if(session!=null)session.close();stopSignaling();super.onDestroy();}
|
||||
@Override protected void onStop(){foreground=false;appUpdates.setEnabled(false);if(privacyDialog!=null)privacyDialog.dismiss();pageHost.finish();
|
||||
// The system document picker is part of this session's explicit file action.
|
||||
if(documentSession==null||documentSession!=session||isFinishing()){if(session!=null)end(isFinishing()?"":"应用已进入后台,会话已结束");stopSignaling();}
|
||||
super.onStop();}
|
||||
@Override protected void onDestroy(){mainHandler.removeCallbacks(documentTimeout);documentSession=null;documentToSave=null;appUpdates.setEnabled(false);pageHost.finish();resetRemoteVersionCheck();cancelVideoSettingsTimeout();previews.removeListener(previewChanged);if(privacyDialog!=null)privacyDialog.dismiss();if(previewDialog!=null)previewDialog.dismiss();announcementInbox.close();if(disconnectDialog!=null)disconnectDialog.dismiss();if(passwordDialog!=null)passwordDialog.dismiss();if(progressDialog!=null)progressDialog.dismiss();if(session!=null)session.close();stopSignaling();super.onDestroy();}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,8 @@ final class NativeSession implements AutoCloseable {
|
||||
void videoSize(int width, int height);
|
||||
void clipboard(String text);
|
||||
void statistics(RemoteNetworkStatistics.Snapshot value);
|
||||
default void fileTransfer(String name,double progress,boolean sending,File received) { }
|
||||
default void controlFeedback(String message) { }
|
||||
default void signaling(int state, String deviceId) { }
|
||||
default void presence(JSONObject message) { }
|
||||
default void announcement(JSONObject message) { }
|
||||
@@ -52,6 +54,11 @@ final class NativeSession implements AutoCloseable {
|
||||
private volatile boolean closed;
|
||||
private Surface surface;
|
||||
private volatile AudioPlayer audio;
|
||||
private final SessionDocuments documents;
|
||||
private boolean sendingFile;
|
||||
private final Runnable fileTick=new Runnable(){public void run(){
|
||||
execute(()->{boolean sending=connected&&handle!=0&&nPumpFiles(handle);if(!closed)main.postDelayed(this,sending?10:1000);});
|
||||
}};
|
||||
private final RemoteNetworkStatistics networkStatistics=new RemoteNetworkStatistics();
|
||||
private final Runnable statisticsTick=new Runnable(){public void run(){
|
||||
if(closed)return;
|
||||
@@ -67,6 +74,7 @@ final class NativeSession implements AutoCloseable {
|
||||
this.host = host; this.port = port; this.remote = remote; this.password = password;
|
||||
scope = host.toLowerCase(java.util.Locale.ROOT) + ":" + port;
|
||||
secrets = new SecretStore(this.context);
|
||||
documents = new SessionDocuments(this.context);
|
||||
}
|
||||
void start() {
|
||||
if (started || closed) return;
|
||||
@@ -80,6 +88,7 @@ final class NativeSession implements AutoCloseable {
|
||||
if (!logs.isDirectory() && !logs.mkdirs()) throw new IllegalStateException("Log directory unavailable");
|
||||
handle = nCreate(this, host, port, identity, logs.getAbsolutePath(), certificates.getAbsolutePath());
|
||||
if (handle == 0) { fail("原生连接初始化失败"); return; }
|
||||
nFileDirectory(handle,new File(documents.directory,"received").getAbsolutePath());
|
||||
if (surface != null && surface.isValid()) nSurface(handle, surface);
|
||||
} catch (Exception error) { fail("安全存储或证书初始化失败,请重试或清除应用数据"); }
|
||||
});
|
||||
@@ -169,7 +178,7 @@ final class NativeSession implements AutoCloseable {
|
||||
connected = true; main.removeCallbacks(timeout); nReady(handle);
|
||||
audio = new AudioPlayer();
|
||||
nControl(handle, 2, 1);
|
||||
ui(() -> { listener.connected(); if(!closed)statisticsTick.run(); });
|
||||
ui(() -> { listener.connected(); if(!closed){statisticsTick.run();fileTick.run();} });
|
||||
} else if (status == 0 || status == 2) ui(() -> listener.status("正在建立安全连接…"));
|
||||
else if (status == 6) ui(() -> { listener.passwordRejected(); close(); });
|
||||
else {
|
||||
@@ -186,6 +195,11 @@ final class NativeSession implements AutoCloseable {
|
||||
else if (type == 9 && signalReady && presenceRequested) {
|
||||
long generation=event.optLong("generation");
|
||||
if(generation==signalGeneration)ui(()->{if(generation==signalGeneration)listener.presence(event);});
|
||||
} else if(type==10){
|
||||
String name=event.getString("name"),path=event.optString("path");
|
||||
double progress=event.getDouble("progress");boolean sending=event.getBoolean("sending");
|
||||
ui(()->{if(sending&&(progress<0||progress>=1))sendingFile=false;
|
||||
listener.fileTransfer(name,progress,sending,path.isEmpty()?null:new File(path));});
|
||||
}
|
||||
} catch (Exception error) {
|
||||
if (type == 3) fail("设备身份保存失败,请检查安全存储");
|
||||
@@ -232,14 +246,36 @@ final class NativeSession implements AutoCloseable {
|
||||
if (bytes.length == 0 || bytes.length > 128 * 1024) return;
|
||||
execute(() -> { if (connected) nClipboard(handle, bytes); });
|
||||
}
|
||||
void secureAttention(){execute(()->{
|
||||
boolean sent=connected&&nSecureAttention(handle);
|
||||
ui(()->listener.controlFeedback(sent?"已发送 Ctrl+Alt+Del":"指令发送失败,请重试"));
|
||||
});}
|
||||
void sendFile(android.net.Uri uri){
|
||||
if(closed)return;
|
||||
if(sendingFile){listener.controlFeedback("请等待当前文件发送完成");return;}
|
||||
sendingFile=true;listener.fileTransfer("正在读取所选文件…",0,true,null);
|
||||
documents.prepare(uri,new SessionDocuments.Prepared(){
|
||||
public void ready(File file,String name){execute(()->{
|
||||
if(!connected||!nSendFile(handle,file.getAbsolutePath(),name.getBytes(StandardCharsets.UTF_8))){
|
||||
file.delete();ui(()->{sendingFile=false;listener.fileTransfer(name,-1,true,null);});
|
||||
}
|
||||
});}
|
||||
public void failed(){ui(()->{sendingFile=false;listener.fileTransfer("所选文件(无法读取或空间不足)",-1,true,null);});}
|
||||
});
|
||||
}
|
||||
void saveFile(File file,android.net.Uri destination){
|
||||
if(closed)return;
|
||||
documents.save(file,destination,success->ui(()->listener.controlFeedback(success?"文件已保存":"文件保存失败,请重试")));
|
||||
}
|
||||
@Override public void close() {
|
||||
if (closed) return;
|
||||
closed = true; main.removeCallbacks(timeout);main.removeCallbacks(statisticsTick);
|
||||
closed = true; main.removeCallbacks(timeout);main.removeCallbacks(statisticsTick);main.removeCallbacks(fileTick);
|
||||
RTC.execute(() -> {
|
||||
main.removeCallbacks(timeout);
|
||||
if (audio != null) { audio.close(); audio = null; }
|
||||
password = "";
|
||||
if (handle != 0) { nDestroy(handle); handle = 0; }
|
||||
documents.close();
|
||||
surface = null;
|
||||
});
|
||||
}
|
||||
@@ -256,6 +292,10 @@ final class NativeSession implements AutoCloseable {
|
||||
private static native boolean nAnnouncementRequest(long handle,byte[] data);
|
||||
private static native boolean nPresenceRequest(long handle,byte[] data);
|
||||
private static native void nClipboard(long handle, byte[] data);
|
||||
private static native boolean nSecureAttention(long handle);
|
||||
private static native void nFileDirectory(long handle,String directory);
|
||||
private static native boolean nSendFile(long handle,String path,byte[] name);
|
||||
private static native boolean nPumpFiles(long handle);
|
||||
private static native void nSurface(long handle, Surface surface);
|
||||
private static native void nDestroy(long handle);
|
||||
}
|
||||
|
||||
@@ -54,6 +54,8 @@ final class RemoteVideoView extends FrameLayout implements SurfaceHolder.Callbac
|
||||
point(current); return true;
|
||||
}
|
||||
});
|
||||
// Let the remote desktop detect double-clicks; the default listener consumes the second tap.
|
||||
gestures.setOnDoubleTapListener(null);
|
||||
scales = new ScaleGestureDetector(context, new ScaleGestureDetector.SimpleOnScaleGestureListener() {
|
||||
@Override public boolean onScaleBegin(ScaleGestureDetector detector) {
|
||||
if (!viewport.contains(detector.getFocusX(), detector.getFocusY())) return false;
|
||||
|
||||
@@ -13,6 +13,7 @@ final class SessionControls extends FrameLayout {
|
||||
void disconnect(); void display(); void clipboard(); void key(int code);
|
||||
void chord(int code,int[] modifiers); void shortcut(int code); void text(String value); void mute(); void mouse();
|
||||
void videoSettings(int field,int value);
|
||||
void sendFile(); void saveFile(); void secureAttention();
|
||||
}
|
||||
private final MobileUi ui;
|
||||
private final Actions actions;
|
||||
@@ -23,6 +24,9 @@ final class SessionControls extends FrameLayout {
|
||||
private ScrollView keyboardKeys;
|
||||
private int keyboardPreferredHeight;
|
||||
private TextView networkLabel,videoFeedback;
|
||||
private TextView transferLabel,saveFileButton;
|
||||
private String transferStatus="";
|
||||
private boolean hasReceivedFile;
|
||||
private String menuPage="controls";
|
||||
private RemoteNetworkStatistics.Snapshot network=new RemoteNetworkStatistics().snapshot(0);
|
||||
private RemoteVideoSettings settings=new RemoteVideoSettings(1);
|
||||
@@ -50,7 +54,12 @@ final class SessionControls extends FrameLayout {
|
||||
void resetVideoStatistics(){statistics(new RemoteNetworkStatistics.Snapshot(network.report,0,0,0,Double.NaN,network.rtt));}
|
||||
void statistics(RemoteNetworkStatistics.Snapshot value){network=value;renderNetwork();}
|
||||
void videoSettings(RemoteVideoSettings value){settings=value;renderVideoSettings();}
|
||||
private void closeMenu(){if(menu!=null){removeView(menu);menu=null;}if(shield!=null){removeView(shield);shield=null;}networkLabel=videoFeedback=null;trafficCells=null;trafficRows=null;networkDetails=null;encryptionLock=encryptionOpen=null;settingRows.clear();orb.setContentDescription("展开远程控制菜单");}
|
||||
void fileStatus(String status,boolean received){transferStatus=status;hasReceivedFile=received;renderFileStatus();}
|
||||
private void renderFileStatus(){
|
||||
if(transferLabel!=null){transferLabel.setText(transferStatus);transferLabel.setVisibility(transferStatus.isEmpty()?GONE:VISIBLE);}
|
||||
if(saveFileButton!=null)saveFileButton.setVisibility(hasReceivedFile?VISIBLE:GONE);
|
||||
}
|
||||
private void closeMenu(){if(menu!=null){removeView(menu);menu=null;}if(shield!=null){removeView(shield);shield=null;}networkLabel=videoFeedback=transferLabel=saveFileButton=null;trafficCells=null;trafficRows=null;networkDetails=null;encryptionLock=encryptionOpen=null;settingRows.clear();orb.setContentDescription("展开远程控制菜单");}
|
||||
private void openMenu(String page){
|
||||
closeMenu();menuPage=page;shield=new View(getContext());shield.setOnClickListener(v->closeMenu());addView(shield,new LayoutParams(-1,-1));
|
||||
menu=ui.column();menu.setPadding(ui.dp(12),ui.dp(12),ui.dp(12),ui.dp(12));ui.card(menu,18);menu.setElevation(ui.dp(12));
|
||||
@@ -67,11 +76,12 @@ final class SessionControls extends FrameLayout {
|
||||
if(page.equals("controls")){
|
||||
String[] labels={"键盘","显示器","画面设置",muted?"静音":"声音",relative?"相对鼠标":"绝对鼠标","发送文件","网络状态","Ctrl+Alt+Del","剪贴板"};
|
||||
String[] icons={"keyboard","display","sliders","audio","mouse","folder","chart","lock","clipboard"};
|
||||
Runnable[] clicks={()->{closeMenu();toggleKeyboard();},actions::display,()->openMenu("video"),actions::mute,actions::mouse,()->{},()->openMenu("network"),()->{},actions::clipboard};
|
||||
Runnable[] clicks={()->{closeMenu();toggleKeyboard();},actions::display,()->openMenu("video"),actions::mute,actions::mouse,actions::sendFile,()->openMenu("network"),actions::secureAttention,actions::clipboard};
|
||||
for(int row=0;row<3;row++){LinearLayout line=ui.row();for(int col=0;col<3;col++){int index=row*3+col;LinearLayout control=ui.column();control.setGravity(Gravity.CENTER);control.setBackground(ui.background(0xFFF0F0F2,10));control.addView(ui.icon(icons[index],INK),ui.size(19,19));ui.gap(control,4);TextView caption=ui.text(labels[index],10,false);caption.setSingleLine();caption.setGravity(Gravity.CENTER);control.addView(caption);control.setContentDescription(labels[index]);control.setClickable(true);control.setFocusable(true);control.setOnClickListener(v->clicks[index].run());
|
||||
if(index==5||index==7){control.setEnabled(false);control.setAlpha(.38f);control.setContentDescription(labels[index]+",暂不支持");}
|
||||
LinearLayout.LayoutParams p=new LinearLayout.LayoutParams(0,ui.dp(52),1);if(col>0)p.leftMargin=ui.dp(7);line.addView(control,p);
|
||||
}body.addView(line);if(row<2)ui.gap(body,7);}
|
||||
ui.gap(body,7);transferLabel=ui.text(transferStatus,11,false);transferLabel.setTextColor(SECONDARY);transferLabel.setAccessibilityLiveRegion(ACCESSIBILITY_LIVE_REGION_POLITE);body.addView(transferLabel);
|
||||
saveFileButton=ui.action("保存收到的文件",BLUE,actions::saveFile);body.addView(saveFileButton);renderFileStatus();
|
||||
}else if(page.equals("network")){
|
||||
networkPage(body);
|
||||
}else{
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
package cn.crossdesk.mobile;
|
||||
|
||||
import android.content.Context;
|
||||
import android.database.Cursor;
|
||||
import android.net.Uri;
|
||||
import android.provider.OpenableColumns;
|
||||
import java.io.*;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
|
||||
/** Private session files; document-provider reads/writes never run on UI/RTC threads. */
|
||||
final class SessionDocuments {
|
||||
interface Prepared { void ready(File file,String name); void failed(); }
|
||||
private final Context context;
|
||||
private final ExecutorService io=Executors.newSingleThreadExecutor(r->new Thread(r,"CrossDesk documents"));
|
||||
final File directory;
|
||||
private volatile boolean closed;
|
||||
SessionDocuments(Context context){
|
||||
this.context=context.getApplicationContext();
|
||||
directory=new File(context.getCacheDir(),"transfers/"+UUID.randomUUID());
|
||||
}
|
||||
void prepare(Uri uri,Prepared callback){
|
||||
io.execute(()->{
|
||||
File staged=null;
|
||||
try{
|
||||
if(closed)return;
|
||||
String name="file";
|
||||
try(Cursor cursor=context.getContentResolver().query(uri,new String[]{OpenableColumns.DISPLAY_NAME},null,null,null)){
|
||||
if(cursor!=null&&cursor.moveToFirst())name=cursor.getString(0);
|
||||
}
|
||||
name=safeName(name);
|
||||
Files.createDirectories(directory.toPath());
|
||||
staged=File.createTempFile("sending-",".part",directory);
|
||||
try(InputStream input=context.getContentResolver().openInputStream(uri);OutputStream output=new FileOutputStream(staged)){
|
||||
if(input==null)throw new IOException("No input");
|
||||
copy(input,output);
|
||||
}
|
||||
if(closed){Files.deleteIfExists(staged.toPath());return;}
|
||||
callback.ready(staged,name);
|
||||
}catch(Exception error){
|
||||
if(staged!=null)staged.delete();
|
||||
if(!closed)callback.failed();
|
||||
}
|
||||
});
|
||||
}
|
||||
void save(File file,Uri uri,java.util.function.Consumer<Boolean> completion){
|
||||
io.execute(()->{
|
||||
boolean success=false;
|
||||
try{
|
||||
if(closed)return;
|
||||
try(InputStream input=new FileInputStream(file);OutputStream output=context.getContentResolver().openOutputStream(uri,"wt")){
|
||||
if(output==null)throw new IOException("No output");
|
||||
copy(input,output);
|
||||
}
|
||||
success=true;
|
||||
}catch(Exception ignored){ }
|
||||
if(!closed)completion.accept(success);
|
||||
});
|
||||
}
|
||||
private void copy(InputStream input,OutputStream output)throws IOException{
|
||||
byte[] bytes=new byte[64*1024];int count;
|
||||
while((count=input.read(bytes))!=-1){if(closed)throw new IOException("Session closed");output.write(bytes,0,count);}
|
||||
}
|
||||
static String safeName(String value){
|
||||
if(value==null)return "file";
|
||||
value=value.replace('\\','/');value=value.substring(value.lastIndexOf('/')+1).replaceAll("[\\p{Cntrl}:]","_");
|
||||
return value.isEmpty()||value.equals(".")||value.equals("..")||value.getBytes(StandardCharsets.UTF_8).length>240?"file":value;
|
||||
}
|
||||
// Called after native file handles have closed. Queue cleanup behind any provider I/O.
|
||||
void close(){closed=true;io.execute(()->remove(directory));io.shutdown();}
|
||||
private static void remove(File file){File[] children=file.listFiles();if(children!=null)for(File child:children)remove(child);file.delete();}
|
||||
}
|
||||
Reference in New Issue
Block a user