File indexing completed on 2024-12-22 04:41:40
0001 /* 0002 * SPDX-FileCopyrightText: 2018 Erik Duisters <e.duisters1@gmail.com> 0003 * 0004 * SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL 0005 */ 0006 0007 package org.kde.kdeconnect.Plugins.SftpPlugin; 0008 0009 import android.content.ContentResolver; 0010 import android.content.Context; 0011 import android.content.Intent; 0012 import android.content.pm.PackageManager; 0013 import android.database.Cursor; 0014 import android.net.Uri; 0015 import android.os.Build; 0016 import android.provider.DocumentsContract; 0017 import android.text.TextUtils; 0018 import android.util.Log; 0019 0020 import androidx.annotation.Nullable; 0021 0022 import org.apache.sshd.common.file.SshFile; 0023 import org.kde.kdeconnect.Helpers.FilesHelper; 0024 0025 import java.io.File; 0026 import java.io.FileNotFoundException; 0027 import java.io.IOException; 0028 import java.io.InputStream; 0029 import java.io.OutputStream; 0030 import java.util.ArrayList; 0031 import java.util.Collections; 0032 import java.util.EnumSet; 0033 import java.util.HashMap; 0034 import java.util.HashSet; 0035 import java.util.List; 0036 import java.util.Map; 0037 import java.util.Set; 0038 0039 public class AndroidSafSshFile implements SshFile { 0040 private static final String TAG = AndroidSafSshFile.class.getSimpleName(); 0041 0042 private final String virtualFileName; 0043 private DocumentInfo documentInfo; 0044 private Uri parentUri; 0045 private final AndroidSafFileSystemView fileSystemView; 0046 0047 AndroidSafSshFile(final AndroidSafFileSystemView fileSystemView, Uri parentUri, Uri uri, String virtualFileName) { 0048 this.fileSystemView = fileSystemView; 0049 this.parentUri = parentUri; 0050 this.documentInfo = new DocumentInfo(fileSystemView.context, uri); 0051 this.virtualFileName = virtualFileName; 0052 } 0053 0054 @Override 0055 public String getAbsolutePath() { 0056 return virtualFileName; 0057 } 0058 0059 @Override 0060 public String getName() { 0061 /* From NativeSshFile, looks a lot like new File(virtualFileName).getName() to me */ 0062 0063 // strip the last '/' 0064 String shortName = virtualFileName; 0065 int filelen = virtualFileName.length(); 0066 if (shortName.charAt(filelen - 1) == File.separatorChar) { 0067 shortName = shortName.substring(0, filelen - 1); 0068 } 0069 0070 // return from the last '/' 0071 int slashIndex = shortName.lastIndexOf(File.separatorChar); 0072 if (slashIndex != -1) { 0073 shortName = shortName.substring(slashIndex + 1); 0074 } 0075 0076 return shortName; 0077 } 0078 0079 @Override 0080 public String getOwner() { 0081 return fileSystemView.userName; 0082 } 0083 0084 @Override 0085 public boolean isDirectory() { 0086 return documentInfo.isDirectory; 0087 } 0088 0089 @Override 0090 public boolean isFile() { 0091 return documentInfo.isFile; 0092 } 0093 0094 @Override 0095 public boolean doesExist() { 0096 return documentInfo.exists; 0097 } 0098 0099 @Override 0100 public long getSize() { 0101 return documentInfo.length; 0102 } 0103 0104 @Override 0105 public long getLastModified() { 0106 return documentInfo.lastModified; 0107 } 0108 0109 @Override 0110 public boolean setLastModified(long time) { 0111 //TODO 0112 /* Throws UnsupportedOperationException on API 26 0113 try { 0114 ContentValues updateValues = new ContentValues(); 0115 updateValues.put(DocumentsContract.Document.COLUMN_LAST_MODIFIED, time); 0116 result = fileSystemView.context.getContentResolver().update(documentInfo.uri, updateValues, null, null) != 0; 0117 documentInfo.lastModified = time; 0118 } catch (NullPointerException ignored) {} 0119 */ 0120 return true; 0121 } 0122 0123 @Override 0124 public boolean isReadable() { 0125 return documentInfo.canRead; 0126 } 0127 0128 @Override 0129 public boolean isWritable() { 0130 return documentInfo.canWrite; 0131 } 0132 0133 @Override 0134 public boolean isExecutable() { 0135 return documentInfo.isDirectory; 0136 } 0137 0138 @Override 0139 public boolean isRemovable() { 0140 Log.d(TAG, "isRemovable() - is this ever called?"); 0141 0142 return false; 0143 } 0144 0145 public SshFile getParentFile() { 0146 Log.d(TAG,"getParentFile() - is this ever called"); 0147 0148 return null; 0149 } 0150 0151 @Override 0152 public boolean delete() { 0153 boolean ret; 0154 0155 try { 0156 ret = DocumentsContract.deleteDocument(fileSystemView.context.getContentResolver(), documentInfo.uri); 0157 } catch (FileNotFoundException e) { 0158 ret = false; 0159 } 0160 0161 return ret; 0162 } 0163 0164 @Override 0165 public boolean create() { 0166 return create(parentUri, FilesHelper.getMimeTypeFromFile(virtualFileName), getName()); 0167 } 0168 0169 private boolean create(Uri parentUri, String mimeType, String name) { 0170 Uri uri = null; 0171 try { 0172 uri = DocumentsContract.createDocument(fileSystemView.context.getContentResolver(), parentUri, mimeType, name); 0173 0174 if (uri != null) { 0175 documentInfo = new DocumentInfo(fileSystemView.context, uri); 0176 if (!name.equals(documentInfo.displayName)) { 0177 delete(); 0178 return false; 0179 } 0180 } 0181 } catch (FileNotFoundException ignored) {} 0182 0183 return uri != null; 0184 } 0185 0186 @Override 0187 public void truncate() { 0188 if (documentInfo.length > 0) { 0189 delete(); 0190 create(); 0191 } 0192 } 0193 0194 @Override 0195 public boolean move(final SshFile dest) { 0196 boolean success = false; 0197 0198 Uri destParentUri = ((AndroidSafSshFile)dest).parentUri; 0199 0200 if (destParentUri.equals(parentUri)) { 0201 //Rename 0202 try { 0203 Uri newUri = DocumentsContract.renameDocument(fileSystemView.context.getContentResolver(), documentInfo.uri, dest.getName()); 0204 if (newUri != null) { 0205 success = true; 0206 documentInfo.uri = newUri; 0207 } 0208 } catch (FileNotFoundException ignored) {} 0209 } else { 0210 // Move: 0211 String sourceTreeDocumentId = DocumentsContract.getTreeDocumentId(parentUri); 0212 String destTreeDocumentId = DocumentsContract.getTreeDocumentId(((AndroidSafSshFile) dest).parentUri); 0213 0214 if (sourceTreeDocumentId.equals(destTreeDocumentId) && Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { 0215 try { 0216 Uri newUri = DocumentsContract.moveDocument(fileSystemView.context.getContentResolver(), documentInfo.uri, parentUri, destParentUri); 0217 if (newUri != null) { 0218 success = true; 0219 parentUri = destParentUri; 0220 documentInfo.uri = newUri; 0221 } 0222 } catch (Exception e) { 0223 Log.e(TAG,"DocumentsContract.moveDocument() threw an exception", e); 0224 } 0225 } else { 0226 try { 0227 if (dest.create()) { 0228 try (InputStream in = createInputStream(0); OutputStream out = dest.createOutputStream(0)) { 0229 byte[] buffer = new byte[10 * 1024]; 0230 int read; 0231 0232 while ((read = in.read(buffer)) > 0) { 0233 out.write(buffer, 0, read); 0234 } 0235 0236 out.flush(); 0237 0238 delete(); 0239 success = true; 0240 } catch (IOException e) { 0241 if (dest.doesExist()) { 0242 dest.delete(); 0243 } 0244 } 0245 } 0246 } catch (IOException ignored) {} 0247 } 0248 } 0249 0250 return success; 0251 } 0252 0253 @Override 0254 public boolean mkdir() { 0255 return create(parentUri, DocumentsContract.Document.MIME_TYPE_DIR, getName()); 0256 } 0257 0258 @Override 0259 public List<SshFile> listSshFiles() { 0260 if (!documentInfo.isDirectory) { 0261 return null; 0262 } 0263 0264 final ContentResolver resolver = fileSystemView.context.getContentResolver(); 0265 final Uri childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(documentInfo.uri, DocumentsContract.getDocumentId(documentInfo.uri)); 0266 final ArrayList<AndroidSafSshFile> results = new ArrayList<>(); 0267 0268 Cursor c = resolver.query(childrenUri, new String[] 0269 { DocumentsContract.Document.COLUMN_DOCUMENT_ID, DocumentsContract.Document.COLUMN_DISPLAY_NAME }, null, null, null); 0270 0271 while (c != null && c.moveToNext()) { 0272 final String documentId = c.getString(c.getColumnIndex(DocumentsContract.Document.COLUMN_DOCUMENT_ID)); 0273 final String displayName = c.getString(c.getColumnIndex(DocumentsContract.Document.COLUMN_DISPLAY_NAME)); 0274 final Uri documentUri = DocumentsContract.buildDocumentUriUsingTree(documentInfo.uri, documentId); 0275 results.add(new AndroidSafSshFile(fileSystemView, parentUri, documentUri, virtualFileName + File.separatorChar + displayName)); 0276 } 0277 0278 if (c != null) { 0279 c.close(); 0280 } 0281 0282 return Collections.unmodifiableList(results); 0283 } 0284 0285 @Override 0286 public OutputStream createOutputStream(final long offset) throws IOException { 0287 if (offset != 0) { 0288 throw new IOException("Seeking is not supported."); 0289 } 0290 return fileSystemView.context.getContentResolver().openOutputStream(documentInfo.uri); 0291 } 0292 0293 @Override 0294 public InputStream createInputStream(final long offset) throws IOException { 0295 InputStream s = fileSystemView.context.getContentResolver().openInputStream(documentInfo.uri); 0296 final long sought = s.skip(offset); 0297 if (sought != offset) { 0298 throw new IOException(String.format("Unable to seek %d bytes, sought %d bytes.", offset, sought)); 0299 } 0300 return s; 0301 } 0302 0303 @Override 0304 public void handleClose() { 0305 // Nop 0306 } 0307 0308 @Override 0309 public Map<Attribute, Object> getAttributes(boolean followLinks) { 0310 Map<SshFile.Attribute, Object> attributes = new HashMap<>(); 0311 for (SshFile.Attribute attr : SshFile.Attribute.values()) { 0312 switch (attr) { 0313 case Uid: 0314 case Gid: 0315 case NLink: 0316 continue; 0317 } 0318 attributes.put(attr, getAttribute(attr, followLinks)); 0319 } 0320 0321 return attributes; 0322 } 0323 0324 @Override 0325 public Object getAttribute(Attribute attribute, boolean followLinks) { 0326 Object ret; 0327 0328 switch (attribute) { 0329 case Size: 0330 ret = documentInfo.length; 0331 break; 0332 case Uid: 0333 case Gid: 0334 ret = 1; 0335 break; 0336 case Owner: 0337 case Group: 0338 ret = getOwner(); 0339 break; 0340 case IsDirectory: 0341 ret = documentInfo.isDirectory; 0342 break; 0343 case IsRegularFile: 0344 ret = documentInfo.isFile; 0345 break; 0346 case IsSymbolicLink: 0347 ret = false; 0348 break; 0349 case Permissions: 0350 Set<Permission> tmp = new HashSet<>(); 0351 if (documentInfo.canRead) { 0352 tmp.add(SshFile.Permission.UserRead); 0353 tmp.add(SshFile.Permission.GroupRead); 0354 tmp.add(SshFile.Permission.OthersRead); 0355 } 0356 if (documentInfo.canWrite) { 0357 tmp.add(SshFile.Permission.UserWrite); 0358 tmp.add(SshFile.Permission.GroupWrite); 0359 tmp.add(SshFile.Permission.OthersWrite); 0360 } 0361 if (isExecutable()) { 0362 tmp.add(SshFile.Permission.UserExecute); 0363 tmp.add(SshFile.Permission.GroupExecute); 0364 tmp.add(SshFile.Permission.OthersExecute); 0365 } 0366 ret = tmp.isEmpty() 0367 ? EnumSet.noneOf(SshFile.Permission.class) 0368 : EnumSet.copyOf(tmp); 0369 break; 0370 case CreationTime: 0371 case LastModifiedTime: 0372 case LastAccessTime: 0373 ret = documentInfo.lastModified; 0374 break; 0375 case NLink: 0376 ret = 0; 0377 break; 0378 default: 0379 ret = null; 0380 break; 0381 } 0382 0383 return ret; 0384 } 0385 0386 @Override 0387 public void setAttributes(Map<Attribute, Object> attributes) { 0388 //TODO: Using Java 7 NIO it should be possible to implement setting a number of attributes but does SaF allow that? 0389 } 0390 0391 @Override 0392 public void setAttribute(Attribute attribute, Object value) {} 0393 0394 @Override 0395 public String readSymbolicLink() throws IOException { 0396 throw new IOException("Not Implemented"); 0397 } 0398 0399 @Override 0400 public void createSymbolicLink(SshFile destination) throws IOException { 0401 throw new IOException("Not Implemented"); 0402 } 0403 0404 /** 0405 * Retrieve all file info using 1 query to speed things up 0406 * The only fields guaranteed to be initialized are uri and exists 0407 */ 0408 private static class DocumentInfo { 0409 private Uri uri; 0410 private boolean exists; 0411 private boolean canRead; 0412 private boolean canWrite; 0413 private boolean isDirectory; 0414 private boolean isFile; 0415 private long lastModified; 0416 private long length; 0417 @Nullable 0418 private String displayName; 0419 0420 private static final String[] columns; 0421 0422 static { 0423 columns = new String[]{ 0424 DocumentsContract.Document.COLUMN_DOCUMENT_ID, 0425 DocumentsContract.Document.COLUMN_MIME_TYPE, 0426 DocumentsContract.Document.COLUMN_DISPLAY_NAME, 0427 DocumentsContract.Document.COLUMN_LAST_MODIFIED, 0428 //DocumentsContract.Document.COLUMN_ICON, 0429 DocumentsContract.Document.COLUMN_FLAGS, 0430 DocumentsContract.Document.COLUMN_SIZE 0431 }; 0432 } 0433 0434 /* 0435 Based on https://github.com/rcketscientist/DocumentActivity 0436 Extracted from android.support.v4.provider.DocumentsContractAPI19 and android.support.v4.provider.DocumentsContractAPI21 0437 */ 0438 private DocumentInfo(Context c, Uri uri) 0439 { 0440 this.uri = uri; 0441 0442 try (Cursor cursor = c.getContentResolver().query(uri, columns, null, null, null)) { 0443 exists = cursor != null && cursor.getCount() > 0; 0444 0445 if (!exists) 0446 return; 0447 0448 cursor.moveToFirst(); 0449 0450 //String documentId = cursor.getString(cursor.getColumnIndex(DocumentsContract.Document.COLUMN_DOCUMENT_ID)); 0451 0452 final boolean readPerm = c.checkCallingOrSelfUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION) 0453 == PackageManager.PERMISSION_GRANTED; 0454 final boolean writePerm = c.checkCallingOrSelfUriPermission(uri, Intent.FLAG_GRANT_WRITE_URI_PERMISSION) 0455 == PackageManager.PERMISSION_GRANTED; 0456 0457 final int flags = cursor.getInt(cursor.getColumnIndex(DocumentsContract.Document.COLUMN_FLAGS)); 0458 final boolean supportsDelete = (flags & DocumentsContract.Document.FLAG_SUPPORTS_DELETE) != 0; 0459 final boolean supportsCreate = (flags & DocumentsContract.Document.FLAG_DIR_SUPPORTS_CREATE) != 0; 0460 final boolean supportsWrite = (flags & DocumentsContract.Document.FLAG_SUPPORTS_WRITE) != 0; 0461 String mimeType = cursor.getString(cursor.getColumnIndex(DocumentsContract.Document.COLUMN_MIME_TYPE)); 0462 final boolean hasMime = !TextUtils.isEmpty(mimeType); 0463 0464 isDirectory = DocumentsContract.Document.MIME_TYPE_DIR.equals(mimeType); 0465 isFile = !isDirectory && hasMime; 0466 0467 canRead = readPerm && hasMime; 0468 canWrite = writePerm && (supportsDelete || (isDirectory && supportsCreate) || (hasMime && supportsWrite)); 0469 0470 displayName = cursor.getString(cursor.getColumnIndex(DocumentsContract.Document.COLUMN_DISPLAY_NAME)); 0471 lastModified = cursor.getLong(cursor.getColumnIndex(DocumentsContract.Document.COLUMN_LAST_MODIFIED)); 0472 length = cursor.getLong(cursor.getColumnIndex(DocumentsContract.Document.COLUMN_SIZE)); 0473 } catch (IllegalArgumentException e) { 0474 //File does not exist, it's probably going to be created 0475 exists = false; 0476 canWrite = true; 0477 } 0478 } 0479 } 0480 }