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 }