Changeset View
Changeset View
Standalone View
Standalone View
src/org/kde/kdeconnect/Plugins/SftpPlugin/AndroidSafSshFile.java
- This file was added.
1 | /* | ||||
---|---|---|---|---|---|
2 | * Copyright 2018 Erik Duisters <e.duisters1@gmail.com> | ||||
3 | * | ||||
4 | * This program is free software; you can redistribute it and/or | ||||
5 | * modify it under the terms of the GNU General Public License as | ||||
6 | * published by the Free Software Foundation; either version 2 of | ||||
7 | * the License or (at your option) version 3 or any later version | ||||
8 | * accepted by the membership of KDE e.V. (or its successor approved | ||||
9 | * by the membership of KDE e.V.), which shall act as a proxy | ||||
10 | * defined in Section 14 of version 3 of the license. | ||||
11 | * | ||||
12 | * This program is distributed in the hope that it will be useful, | ||||
13 | * but WITHOUT ANY WARRANTY; without even the implied warranty of | ||||
14 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | ||||
15 | * GNU General Public License for more details. | ||||
16 | * | ||||
17 | * You should have received a copy of the GNU General Public License | ||||
18 | * along with this program. If not, see <http://www.gnu.org/licenses/>. | ||||
19 | */ | ||||
20 | | ||||
21 | package org.kde.kdeconnect.Plugins.SftpPlugin; | ||||
22 | | ||||
23 | import android.annotation.TargetApi; | ||||
24 | import android.content.ContentResolver; | ||||
25 | import android.content.Context; | ||||
26 | import android.content.Intent; | ||||
27 | import android.content.pm.PackageManager; | ||||
28 | import android.database.Cursor; | ||||
29 | import android.net.Uri; | ||||
30 | import android.os.Build; | ||||
31 | import android.provider.DocumentsContract; | ||||
32 | import android.text.TextUtils; | ||||
33 | import android.util.Log; | ||||
34 | | ||||
35 | import org.apache.sshd.common.file.SshFile; | ||||
36 | import org.kde.kdeconnect.Helpers.FilesHelper; | ||||
37 | | ||||
38 | import java.io.File; | ||||
39 | import java.io.FileNotFoundException; | ||||
40 | import java.io.IOException; | ||||
41 | import java.io.InputStream; | ||||
42 | import java.io.OutputStream; | ||||
43 | import java.util.ArrayList; | ||||
44 | import java.util.Collections; | ||||
45 | import java.util.EnumSet; | ||||
46 | import java.util.HashMap; | ||||
47 | import java.util.HashSet; | ||||
48 | import java.util.List; | ||||
49 | import java.util.Map; | ||||
50 | import java.util.Set; | ||||
51 | | ||||
52 | import androidx.annotation.Nullable; | ||||
53 | | ||||
54 | @TargetApi(21) | ||||
55 | public class AndroidSafSshFile implements SshFile { | ||||
56 | private static final String TAG = AndroidSafSshFile.class.getSimpleName(); | ||||
57 | | ||||
58 | private final String virtualFileName; | ||||
59 | private DocumentInfo documentInfo; | ||||
60 | private Uri parentUri; | ||||
61 | private final AndroidSafFileSystemView fileSystemView; | ||||
62 | | ||||
63 | AndroidSafSshFile(final AndroidSafFileSystemView fileSystemView, Uri parentUri, Uri uri, String virtualFileName) { | ||||
64 | this.fileSystemView = fileSystemView; | ||||
65 | this.parentUri = parentUri; | ||||
66 | this.documentInfo = new DocumentInfo(fileSystemView.context, uri); | ||||
67 | this.virtualFileName = virtualFileName; | ||||
68 | } | ||||
69 | | ||||
70 | @Override | ||||
71 | public String getAbsolutePath() { | ||||
72 | return virtualFileName; | ||||
73 | } | ||||
74 | | ||||
75 | @Override | ||||
76 | public String getName() { | ||||
77 | /* From NativeSshFile, looks a lot like new File(virtualFileName).getName() to me */ | ||||
78 | | ||||
79 | // strip the last '/' | ||||
80 | String shortName = virtualFileName; | ||||
81 | int filelen = virtualFileName.length(); | ||||
82 | if (shortName.charAt(filelen - 1) == File.separatorChar) { | ||||
83 | shortName = shortName.substring(0, filelen - 1); | ||||
84 | } | ||||
85 | | ||||
86 | // return from the last '/' | ||||
87 | int slashIndex = shortName.lastIndexOf(File.separatorChar); | ||||
88 | if (slashIndex != -1) { | ||||
89 | shortName = shortName.substring(slashIndex + 1); | ||||
90 | } | ||||
91 | | ||||
92 | return shortName; | ||||
93 | } | ||||
94 | | ||||
95 | @Override | ||||
96 | public String getOwner() { | ||||
97 | return fileSystemView.userName; | ||||
98 | } | ||||
99 | | ||||
100 | @Override | ||||
101 | public boolean isDirectory() { | ||||
102 | return documentInfo.isDirectory; | ||||
103 | } | ||||
104 | | ||||
105 | @Override | ||||
106 | public boolean isFile() { | ||||
107 | return documentInfo.isFile; | ||||
108 | } | ||||
109 | | ||||
110 | @Override | ||||
111 | public boolean doesExist() { | ||||
112 | return documentInfo.exists; | ||||
113 | } | ||||
114 | | ||||
115 | @Override | ||||
116 | public long getSize() { | ||||
117 | return documentInfo.length; | ||||
118 | } | ||||
119 | | ||||
120 | @Override | ||||
121 | public long getLastModified() { | ||||
122 | return documentInfo.lastModified; | ||||
123 | } | ||||
124 | | ||||
125 | @Override | ||||
126 | public boolean setLastModified(long time) { | ||||
127 | //TODO | ||||
128 | /* Throws UnsupportedOperationException on API 26 | ||||
129 | try { | ||||
130 | ContentValues updateValues = new ContentValues(); | ||||
131 | updateValues.put(DocumentsContract.Document.COLUMN_LAST_MODIFIED, time); | ||||
132 | result = fileSystemView.context.getContentResolver().update(documentInfo.uri, updateValues, null, null) != 0; | ||||
133 | documentInfo.lastModified = time; | ||||
134 | } catch (NullPointerException ignored) {} | ||||
135 | */ | ||||
136 | return true; | ||||
137 | } | ||||
138 | | ||||
139 | @Override | ||||
140 | public boolean isReadable() { | ||||
141 | return documentInfo.canRead; | ||||
142 | } | ||||
143 | | ||||
144 | @Override | ||||
145 | public boolean isWritable() { | ||||
146 | return documentInfo.canWrite; | ||||
147 | } | ||||
148 | | ||||
149 | @Override | ||||
150 | public boolean isExecutable() { | ||||
151 | return documentInfo.isDirectory; | ||||
152 | } | ||||
153 | | ||||
154 | @Override | ||||
155 | public boolean isRemovable() { | ||||
156 | Log.d(TAG, "isRemovable() - is this ever called?"); | ||||
157 | | ||||
158 | return false; | ||||
159 | } | ||||
160 | | ||||
161 | public SshFile getParentFile() { | ||||
162 | Log.d(TAG,"getParentFile() - is this ever called"); | ||||
163 | | ||||
164 | return null; | ||||
165 | } | ||||
166 | | ||||
167 | @Override | ||||
168 | public boolean delete() { | ||||
169 | boolean ret; | ||||
170 | | ||||
171 | try { | ||||
172 | ret = DocumentsContract.deleteDocument(fileSystemView.context.getContentResolver(), documentInfo.uri); | ||||
173 | } catch (FileNotFoundException e) { | ||||
174 | ret = false; | ||||
175 | } | ||||
176 | | ||||
177 | return ret; | ||||
178 | } | ||||
179 | | ||||
180 | @Override | ||||
181 | public boolean create() { | ||||
182 | return create(parentUri, FilesHelper.getMimeTypeFromFile(virtualFileName), getName()); | ||||
183 | } | ||||
184 | | ||||
185 | private boolean create(Uri parentUri, String mimeType, String name) { | ||||
186 | Uri uri = null; | ||||
187 | try { | ||||
188 | uri = DocumentsContract.createDocument(fileSystemView.context.getContentResolver(), parentUri, mimeType, name); | ||||
189 | | ||||
190 | if (uri != null) { | ||||
191 | documentInfo = new DocumentInfo(fileSystemView.context, uri); | ||||
192 | } | ||||
193 | } catch (FileNotFoundException ignored) {} | ||||
194 | | ||||
195 | return uri != null; | ||||
196 | } | ||||
197 | | ||||
198 | @Override | ||||
199 | public void truncate() throws IOException { | ||||
200 | if (documentInfo.length > 0) { | ||||
201 | delete(); | ||||
202 | create(); | ||||
203 | } | ||||
204 | } | ||||
205 | | ||||
206 | @Override | ||||
207 | public boolean move(final SshFile dest) { | ||||
208 | boolean success = false; | ||||
209 | | ||||
210 | Uri destParentUri = ((AndroidSafSshFile)dest).parentUri; | ||||
211 | | ||||
212 | if (destParentUri.equals(parentUri)) { | ||||
213 | //Rename | ||||
214 | try { | ||||
215 | Uri newUri = DocumentsContract.renameDocument(fileSystemView.context.getContentResolver(), documentInfo.uri, dest.getName()); | ||||
216 | if (newUri != null) { | ||||
217 | success = true; | ||||
218 | documentInfo.uri = newUri; | ||||
219 | } | ||||
220 | } catch (FileNotFoundException ignored) {} | ||||
221 | } else { | ||||
222 | // Move: | ||||
223 | String sourceTreeDocumentId = DocumentsContract.getTreeDocumentId(parentUri); | ||||
224 | String destTreeDocumentId = DocumentsContract.getTreeDocumentId(((AndroidSafSshFile) dest).parentUri); | ||||
225 | | ||||
226 | if (sourceTreeDocumentId.equals(destTreeDocumentId) && Build.VERSION.SDK_INT >= 24) { | ||||
227 | try { | ||||
228 | Uri newUri = DocumentsContract.moveDocument(fileSystemView.context.getContentResolver(), documentInfo.uri, parentUri, destParentUri); | ||||
229 | if (newUri != null) { | ||||
230 | success = true; | ||||
231 | parentUri = destParentUri; | ||||
232 | documentInfo.uri = newUri; | ||||
233 | } | ||||
234 | } catch (Exception ignored) { | ||||
235 | Log.e(TAG,"DocumentsContract.moveDocument() threw an exception: " + ignored.getMessage()); | ||||
236 | } | ||||
237 | } else { | ||||
238 | try { | ||||
239 | if (dest.create()) { | ||||
240 | try (InputStream in = createInputStream(0); OutputStream out = dest.createOutputStream(0)) { | ||||
241 | byte[] buffer = new byte[10 * 1024]; | ||||
242 | int read; | ||||
243 | | ||||
244 | while ((read = in.read(buffer)) > 0) { | ||||
245 | out.write(buffer, 0, read); | ||||
246 | } | ||||
247 | | ||||
248 | out.flush(); | ||||
249 | | ||||
250 | delete(); | ||||
251 | success = true; | ||||
252 | } catch (IOException e) { | ||||
253 | if (dest.doesExist()) { | ||||
254 | dest.delete(); | ||||
255 | } | ||||
256 | } | ||||
257 | } | ||||
258 | } catch (IOException ignored) {} | ||||
259 | } | ||||
260 | } | ||||
261 | | ||||
262 | return success; | ||||
263 | } | ||||
264 | | ||||
265 | @Override | ||||
266 | public boolean mkdir() { | ||||
267 | return create(parentUri, DocumentsContract.Document.MIME_TYPE_DIR, getName()); | ||||
268 | } | ||||
269 | | ||||
270 | @Override | ||||
271 | public List<SshFile> listSshFiles() { | ||||
272 | if (!documentInfo.isDirectory) { | ||||
273 | return null; | ||||
274 | } | ||||
275 | | ||||
276 | final ContentResolver resolver = fileSystemView.context.getContentResolver(); | ||||
277 | final Uri childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(documentInfo.uri, DocumentsContract.getDocumentId(documentInfo.uri)); | ||||
278 | final ArrayList<AndroidSafSshFile> results = new ArrayList<>(); | ||||
279 | | ||||
280 | Cursor c = resolver.query(childrenUri, new String[] | ||||
281 | { DocumentsContract.Document.COLUMN_DOCUMENT_ID, DocumentsContract.Document.COLUMN_DISPLAY_NAME }, null, null, null); | ||||
282 | | ||||
283 | while (c != null && c.moveToNext()) { | ||||
284 | final String documentId = c.getString(c.getColumnIndex(DocumentsContract.Document.COLUMN_DOCUMENT_ID)); | ||||
285 | final String displayName = c.getString(c.getColumnIndex(DocumentsContract.Document.COLUMN_DISPLAY_NAME)); | ||||
286 | final Uri documentUri = DocumentsContract.buildDocumentUriUsingTree(documentInfo.uri, documentId); | ||||
287 | results.add(new AndroidSafSshFile(fileSystemView, parentUri, documentUri, virtualFileName + File.separatorChar + displayName)); | ||||
288 | } | ||||
289 | | ||||
290 | if (c != null) { | ||||
291 | c.close(); | ||||
292 | } | ||||
293 | | ||||
294 | return Collections.unmodifiableList(results); | ||||
295 | } | ||||
296 | | ||||
297 | @Override | ||||
298 | public OutputStream createOutputStream(final long offset) throws IOException { | ||||
299 | return fileSystemView.context.getContentResolver().openOutputStream(documentInfo.uri); | ||||
300 | } | ||||
301 | | ||||
302 | @Override | ||||
303 | public InputStream createInputStream(final long offset) throws IOException { | ||||
304 | return fileSystemView.context.getContentResolver().openInputStream(documentInfo.uri); | ||||
305 | } | ||||
306 | | ||||
307 | @Override | ||||
308 | public void handleClose() { | ||||
309 | // Nop | ||||
310 | } | ||||
311 | | ||||
312 | @Override | ||||
313 | public Map<Attribute, Object> getAttributes(boolean followLinks) throws IOException { | ||||
314 | Map<SshFile.Attribute, Object> attributes = new HashMap<>(); | ||||
315 | for (SshFile.Attribute attr : SshFile.Attribute.values()) { | ||||
316 | switch (attr) { | ||||
317 | case Uid: | ||||
318 | case Gid: | ||||
319 | case NLink: | ||||
320 | continue; | ||||
321 | } | ||||
322 | attributes.put(attr, getAttribute(attr, followLinks)); | ||||
323 | } | ||||
324 | | ||||
325 | return attributes; | ||||
326 | } | ||||
327 | | ||||
328 | @Override | ||||
329 | public Object getAttribute(Attribute attribute, boolean followLinks) throws IOException { | ||||
330 | Object ret; | ||||
331 | | ||||
332 | switch (attribute) { | ||||
333 | case Size: | ||||
334 | ret = documentInfo.length; | ||||
335 | break; | ||||
336 | case Uid: | ||||
337 | ret = 1; | ||||
338 | break; | ||||
339 | case Owner: | ||||
340 | ret = getOwner(); | ||||
341 | break; | ||||
342 | case Gid: | ||||
343 | ret = 1; | ||||
344 | break; | ||||
345 | case Group: | ||||
346 | ret = getOwner(); | ||||
347 | break; | ||||
348 | case IsDirectory: | ||||
349 | ret = documentInfo.isDirectory; | ||||
350 | break; | ||||
351 | case IsRegularFile: | ||||
352 | ret = documentInfo.isFile; | ||||
353 | break; | ||||
354 | case IsSymbolicLink: | ||||
355 | ret = false; | ||||
356 | break; | ||||
357 | case Permissions: | ||||
358 | Set<Permission> tmp = new HashSet<>(); | ||||
359 | if (documentInfo.canRead) { | ||||
360 | tmp.add(SshFile.Permission.UserRead); | ||||
361 | tmp.add(SshFile.Permission.GroupRead); | ||||
362 | tmp.add(SshFile.Permission.OthersRead); | ||||
363 | } | ||||
364 | if (documentInfo.canWrite) { | ||||
365 | tmp.add(SshFile.Permission.UserWrite); | ||||
366 | tmp.add(SshFile.Permission.GroupWrite); | ||||
367 | tmp.add(SshFile.Permission.OthersWrite); | ||||
368 | } | ||||
369 | if (isExecutable()) { | ||||
370 | tmp.add(SshFile.Permission.UserExecute); | ||||
371 | tmp.add(SshFile.Permission.GroupExecute); | ||||
372 | tmp.add(SshFile.Permission.OthersExecute); | ||||
373 | } | ||||
374 | ret = tmp.isEmpty() | ||||
375 | ? EnumSet.noneOf(SshFile.Permission.class) | ||||
376 | : EnumSet.copyOf(tmp); | ||||
377 | break; | ||||
378 | case CreationTime: | ||||
379 | ret = documentInfo.lastModified; | ||||
380 | break; | ||||
381 | case LastModifiedTime: | ||||
382 | ret = documentInfo.lastModified; | ||||
383 | break; | ||||
384 | case LastAccessTime: | ||||
385 | ret = documentInfo.lastModified; | ||||
386 | break; | ||||
387 | case NLink: | ||||
388 | ret = 0; | ||||
389 | break; | ||||
390 | default: | ||||
391 | ret = null; | ||||
392 | break; | ||||
393 | } | ||||
394 | | ||||
395 | return ret; | ||||
396 | } | ||||
397 | | ||||
398 | @Override | ||||
399 | public void setAttributes(Map<Attribute, Object> attributes) { | ||||
400 | //TODO: Using Java 7 NIO it should be possible to implement setting a number of attributes but does SaF allow that? | ||||
401 | Log.d(TAG, "setAttributes()"); | ||||
402 | } | ||||
403 | | ||||
404 | @Override | ||||
405 | public void setAttribute(Attribute attribute, Object value) throws IOException { | ||||
406 | Log.d(TAG, "setAttribute()"); | ||||
407 | } | ||||
408 | | ||||
409 | @Override | ||||
410 | public String readSymbolicLink() throws IOException { | ||||
411 | throw new IOException("Not Implemented"); | ||||
412 | } | ||||
413 | | ||||
414 | @Override | ||||
415 | public void createSymbolicLink(SshFile destination) throws IOException { | ||||
416 | throw new IOException("Not Implemented"); | ||||
417 | } | ||||
418 | | ||||
419 | /** | ||||
420 | * Retrieve all file info using 1 query to speed things up | ||||
421 | * The only fields guaranteed to be initialized are uri and exists | ||||
422 | */ | ||||
423 | private static class DocumentInfo { | ||||
424 | private Uri uri; | ||||
425 | private boolean exists; | ||||
426 | @Nullable | ||||
427 | private String documentId; | ||||
428 | private boolean canRead; | ||||
429 | private boolean canWrite; | ||||
430 | @Nullable | ||||
431 | private String mimeType; | ||||
432 | private boolean isDirectory; | ||||
433 | private boolean isFile; | ||||
434 | private long lastModified; | ||||
435 | private long length; | ||||
436 | @Nullable | ||||
437 | private String displayName; | ||||
438 | | ||||
439 | private static final String[] columns; | ||||
440 | | ||||
441 | static { | ||||
442 | columns = new String[]{ | ||||
443 | DocumentsContract.Document.COLUMN_DOCUMENT_ID, | ||||
444 | DocumentsContract.Document.COLUMN_MIME_TYPE, | ||||
445 | DocumentsContract.Document.COLUMN_DISPLAY_NAME, | ||||
446 | DocumentsContract.Document.COLUMN_LAST_MODIFIED, | ||||
447 | //DocumentsContract.Document.COLUMN_ICON, | ||||
448 | DocumentsContract.Document.COLUMN_FLAGS, | ||||
449 | DocumentsContract.Document.COLUMN_SIZE | ||||
450 | }; | ||||
451 | } | ||||
452 | | ||||
453 | /* | ||||
454 | Based on https://github.com/rcketscientist/DocumentActivity | ||||
455 | Extracted from android.support.v4.provider.DocumentsContractAPI19 and android.support.v4.provider.DocumentsContractAPI21 | ||||
456 | */ | ||||
457 | private DocumentInfo(Context c, Uri uri) | ||||
458 | { | ||||
459 | this.uri = uri; | ||||
460 | | ||||
461 | try (Cursor cursor = c.getContentResolver().query(uri, columns, null, null, null)) { | ||||
462 | exists = cursor != null && cursor.getCount() > 0; | ||||
463 | | ||||
464 | if (!exists) | ||||
465 | return; | ||||
466 | | ||||
467 | cursor.moveToFirst(); | ||||
468 | | ||||
469 | documentId = cursor.getString(cursor.getColumnIndex(DocumentsContract.Document.COLUMN_DOCUMENT_ID)); | ||||
470 | | ||||
471 | final boolean readPerm = c.checkCallingOrSelfUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION) | ||||
472 | == PackageManager.PERMISSION_GRANTED; | ||||
473 | final boolean writePerm = c.checkCallingOrSelfUriPermission(uri, Intent.FLAG_GRANT_WRITE_URI_PERMISSION) | ||||
474 | == PackageManager.PERMISSION_GRANTED; | ||||
475 | | ||||
476 | final int flags = cursor.getInt(cursor.getColumnIndex(DocumentsContract.Document.COLUMN_FLAGS)); | ||||
477 | final boolean supportsDelete = (flags & DocumentsContract.Document.FLAG_SUPPORTS_DELETE) != 0; | ||||
478 | final boolean supportsCreate = (flags & DocumentsContract.Document.FLAG_DIR_SUPPORTS_CREATE) != 0; | ||||
479 | final boolean supportsWrite = (flags & DocumentsContract.Document.FLAG_SUPPORTS_WRITE) != 0; | ||||
480 | mimeType = cursor.getString(cursor.getColumnIndex(DocumentsContract.Document.COLUMN_MIME_TYPE)); | ||||
481 | final boolean hasMime = !TextUtils.isEmpty(mimeType); | ||||
482 | | ||||
483 | isDirectory = DocumentsContract.Document.MIME_TYPE_DIR.equals(mimeType); | ||||
484 | isFile = !isDirectory && hasMime; | ||||
485 | | ||||
486 | canRead = readPerm && hasMime; | ||||
487 | canWrite = writePerm && (supportsDelete || (isDirectory && supportsCreate) || (hasMime && supportsWrite)); | ||||
488 | | ||||
489 | displayName = cursor.getString(cursor.getColumnIndex(DocumentsContract.Document.COLUMN_DISPLAY_NAME)); | ||||
490 | lastModified = cursor.getLong(cursor.getColumnIndex(DocumentsContract.Document.COLUMN_LAST_MODIFIED)); | ||||
491 | length = cursor.getLong(cursor.getColumnIndex(DocumentsContract.Document.COLUMN_SIZE)); | ||||
492 | } catch (IllegalArgumentException e) { | ||||
493 | //File does not exist, it's probably going to be created | ||||
494 | exists = false; | ||||
495 | canWrite = true; | ||||
496 | } | ||||
497 | } | ||||
498 | } | ||||
499 | } |