Merge branch 'content-addressable'

Stephen Paul Weber created

* content-addressable:
  Content Addressable Media Storage

Change summary

build.gradle                                                                       |  2 
src/cheogram/java/com/cheogram/android/BobTransfer.java                            | 65 
src/main/java/eu/siacs/conversations/http/HttpDownloadConnection.java              | 13 
src/main/java/eu/siacs/conversations/persistance/DatabaseBackend.java              | 31 
src/main/java/eu/siacs/conversations/persistance/FileBackend.java                  | 60 
src/main/java/eu/siacs/conversations/services/XmppConnectionService.java           | 10 
src/main/java/eu/siacs/conversations/utils/CryptoHelper.java                       | 42 
src/main/java/eu/siacs/conversations/xmpp/jingle/JingleFileTransferConnection.java | 23 
8 files changed, 213 insertions(+), 33 deletions(-)

Detailed changes

build.gradle 🔗

@@ -20,6 +20,7 @@ repositories {
     google()
     mavenCentral()
     jcenter()
+    maven { url 'https://jitpack.io' }
 }
 
 // https://stackoverflow.com/a/38105112/8611
@@ -103,6 +104,7 @@ dependencies {
     implementation 'io.michaelrocks:libphonenumber-android:8.12.49'
     implementation 'io.github.nishkarsh:android-permissions:2.1.6'
     implementation 'androidx.recyclerview:recyclerview:1.1.0'
+    implementation 'com.github.ipld:java-cid:v1.3.1'
     implementation urlFile('https://gateway.pinata.cloud/ipfs/QmeqMiLxHi8AAjXobxr3QTfa1bSSLyAu86YviAqQnjxCjM/libwebrtc.aar', 'libwebrtc.aar')
     // INSERT
 }

src/cheogram/java/com/cheogram/android/BobTransfer.java 🔗

@@ -3,10 +3,15 @@ package com.cheogram.android;
 import android.util.Base64;
 import android.util.Log;
 
+import java.io.ByteArrayInputStream;
+import java.io.File;
 import java.io.IOException;
 import java.io.OutputStream;
 import java.net.URI;
 import java.net.URISyntaxException;
+import java.security.NoSuchAlgorithmException;
+
+import io.ipfs.cid.Cid;
 
 import eu.siacs.conversations.Config;
 import eu.siacs.conversations.R;
@@ -26,29 +31,36 @@ public class BobTransfer implements Transferable {
 	protected Message message;
 	protected URI uri;
 	protected XmppConnectionService xmppConnectionService;
-	protected DownloadableFile file;
+
+	public static Cid cid(URI uri) {
+		String bobCid = uri.getSchemeSpecificPart();
+		if (!bobCid.contains("@") || !bobCid.contains("+")) return null;
+		String[] cidParts = bobCid.split("@")[0].split("\\+");
+		try {
+			return CryptoHelper.cid(CryptoHelper.hexToBytes(cidParts[1]), cidParts[0]);
+		} catch (final NoSuchAlgorithmException e) {
+			return null;
+		}
+	}
 
 	public BobTransfer(Message message, XmppConnectionService xmppConnectionService) throws URISyntaxException {
 		this.message = message;
 		this.xmppConnectionService = xmppConnectionService;
 		this.uri = new URI(message.getFileParams().url);
-		setupFile();
-	}
-
-	private void setupFile() {
-		final String reference = uri.getFragment();
-		if (reference != null && AesGcmURL.IV_KEY.matcher(reference).matches()) {
-			this.file = new DownloadableFile(xmppConnectionService.getCacheDir(), message.getUuid());
-			this.file.setKeyAndIv(CryptoHelper.hexToBytes(reference));
-			Log.d(Config.LOGTAG, "create temporary OMEMO encrypted file: " + this.file.getAbsolutePath() + "(" + message.getMimeType() + ")");
-		} else {
-			this.file = xmppConnectionService.getFileBackend().getFile(message, false);
-		}
 	}
 
 	@Override
 	public boolean start() {
 		if (status == Transferable.STATUS_DOWNLOADING) return true;
+		File f = xmppConnectionService.getFileForCid(cid(uri));
+
+		if (f != null && f.canRead()) {
+			message.setRelativeFilePath(f.getAbsolutePath());
+			finish();
+			message.setTransferable(null);
+			xmppConnectionService.updateConversationUi();
+			return true;
+		}
 
 		if (xmppConnectionService.hasInternetConnection()) {
 			changeStatus(Transferable.STATUS_DOWNLOADING);
@@ -64,29 +76,27 @@ public class BobTransfer implements Transferable {
 					xmppConnectionService.showErrorToastInUi(R.string.download_failed_file_not_found);
 				} else {
 					final String contentType = data.getAttribute("type");
+					String fileExtension = "dat";
 					if (contentType != null) {
-						final String fileExtension = MimeUtils.guessExtensionFromMimeType(contentType);
-						if (fileExtension != null) {
-							xmppConnectionService.getFileBackend().setupRelativeFilePath(message, String.format("%s.%s", message.getUuid(), fileExtension), contentType);
-							Log.d(Config.LOGTAG, "rewriting name for bob based on content type");
-							setupFile();
-						}
+						fileExtension = MimeUtils.guessExtensionFromMimeType(contentType);
 					}
 
 					try {
+						final byte[] bytes = Base64.decode(data.getContent(), Base64.DEFAULT);
+
+						xmppConnectionService.getFileBackend().setupRelativeFilePath(message, new ByteArrayInputStream(bytes), fileExtension);
+						DownloadableFile file = xmppConnectionService.getFileBackend().getFile(message);
 						file.getParentFile().mkdirs();
 						if (!file.exists() && !file.createNewFile()) {
 							throw new IOException(file.getAbsolutePath());
 						}
+
 						final OutputStream outputStream = AbstractConnectionManager.createOutputStream(file, false, false);
-						outputStream.write(Base64.decode(data.getContent(), Base64.DEFAULT));
+						outputStream.write(bytes);
 						outputStream.flush();
 						outputStream.close();
 
-						final boolean privateMessage = message.isPrivateMessage();
-						message.setType(privateMessage ? Message.TYPE_PRIVATE_FILE : Message.TYPE_FILE);
-						xmppConnectionService.getFileBackend().updateFileParams(message, uri.toString());
-						xmppConnectionService.updateMessage(message);
+						finish();
 					} catch (IOException e) {
 						xmppConnectionService.showErrorToastInUi(R.string.download_failed_could_not_write_file);
 					}
@@ -126,4 +136,11 @@ public class BobTransfer implements Transferable {
 		status = newStatus;
 		xmppConnectionService.updateConversationUi();
 	}
+
+	protected void finish() {
+		final boolean privateMessage = message.isPrivateMessage();
+		message.setType(privateMessage ? Message.TYPE_PRIVATE_FILE : Message.TYPE_FILE);
+		xmppConnectionService.getFileBackend().updateFileParams(message, uri.toString(), false);
+		xmppConnectionService.updateMessage(message);
+	}
 }

src/main/java/eu/siacs/conversations/http/HttpDownloadConnection.java 🔗

@@ -192,9 +192,18 @@ public class HttpDownloadConnection implements Transferable {
         if (message.getEncryption() == Message.ENCRYPTION_PGP) {
             notify = message.getConversation().getAccount().getPgpDecryptionService().decrypt(message, notify);
         }
-        mHttpConnectionManager.updateConversationUi(true);
+        DownloadableFile file;
+        final DownloadableFile tmp = mXmppConnectionService.getFileBackend().getFile(message);
+        final String extension = MimeUtils.extractRelevantExtension(tmp.getName());
+        try {
+            mXmppConnectionService.getFileBackend().setupRelativeFilePath(message, new FileInputStream(tmp), extension);
+            file = mXmppConnectionService.getFileBackend().getFile(message);
+            tmp.renameTo(file);
+        } catch (final IOException e) {
+            file = tmp;
+        }
+        mXmppConnectionService.updateMessage(message);
         final boolean notifyAfterScan = notify;
-        final DownloadableFile file = mXmppConnectionService.getFileBackend().getFile(message, true);
         mXmppConnectionService.getFileBackend().updateMediaScanner(file, () -> {
             if (notifyAfterScan) {
                 mXmppConnectionService.getNotificationService().push(message);

src/main/java/eu/siacs/conversations/persistance/DatabaseBackend.java 🔗

@@ -39,6 +39,8 @@ import java.util.Set;
 import java.util.UUID;
 import java.util.concurrent.CopyOnWriteArrayList;
 
+import io.ipfs.cid.Cid;
+
 import eu.siacs.conversations.Config;
 import eu.siacs.conversations.crypto.axolotl.AxolotlService;
 import eu.siacs.conversations.crypto.axolotl.FingerprintStatus;
@@ -252,6 +254,16 @@ public class DatabaseBackend extends SQLiteOpenHelper {
                 db.execSQL("PRAGMA cheogram.user_version = 3");
             }
 
+            if(cheogramVersion < 4) {
+                db.execSQL(
+                    "CREATE TABLE cheogram.cids (" +
+                    "cid TEXT NOT NULL PRIMARY KEY," +
+                    "path TEXT NOT NULL" +
+                    ")"
+                );
+                db.execSQL("PRAGMA cheogram.user_version = 4");
+            }
+
             db.setTransactionSuccessful();
         } finally {
             db.endTransaction();
@@ -721,6 +733,25 @@ public class DatabaseBackend extends SQLiteOpenHelper {
         cursor.close();
     }
 
+    public File getFileForCid(Cid cid) {
+        SQLiteDatabase db = this.getReadableDatabase();
+        Cursor cursor = db.query("cheogram.cids", new String[]{"path"}, "cid=?", new String[]{cid.toString()}, null, null, null);
+        File f = null;
+        if (cursor.moveToNext()) {
+            f = new File(cursor.getString(0));
+        }
+        cursor.close();
+        return f;
+    }
+
+    public void saveCid(Cid cid, File file) {
+        SQLiteDatabase db = this.getWritableDatabase();
+        ContentValues cv = new ContentValues();
+        cv.put("cid", cid.toString());
+        cv.put("path", file.getAbsolutePath());
+        db.insertWithOnConflict("cheogram.cids", null, cv, SQLiteDatabase.CONFLICT_REPLACE);
+    }
+
     public void createConversation(Conversation conversation) {
         SQLiteDatabase db = this.getWritableDatabase();
         db.insert(Conversation.TABLENAME, null, conversation.getContentValues());

src/main/java/eu/siacs/conversations/persistance/FileBackend.java 🔗

@@ -62,6 +62,8 @@ import java.util.List;
 import java.util.Locale;
 import java.util.UUID;
 
+import io.ipfs.cid.Cid;
+
 import eu.siacs.conversations.Config;
 import eu.siacs.conversations.R;
 import eu.siacs.conversations.entities.DownloadableFile;
@@ -712,7 +714,8 @@ public class FileBackend {
         if ("ogg".equals(extension) && type != null && type.startsWith("audio/")) {
             extension = "oga";
         }
-        setupRelativeFilePath(message, String.format("%s.%s", message.getUuid(), extension));
+
+        setupRelativeFilePath(message, uri, extension);
         copyFileToPrivateStorage(mXmppConnectionService.getFileBackend().getFile(message), uri);
     }
 
@@ -850,8 +853,46 @@ public class FileBackend {
                 throw new IllegalStateException("Unknown image format");
         }
         setupRelativeFilePath(message, filename);
-        copyImageToPrivateStorage(getFile(message), image);
-        updateFileParams(message);
+        final File tmp = getFile(message);
+        copyImageToPrivateStorage(tmp, image);
+        final String extension = MimeUtils.extractRelevantExtension(filename);
+        try {
+            setupRelativeFilePath(message, new FileInputStream(tmp), extension);
+        } catch (final FileNotFoundException e) {
+            throw new FileCopyException(R.string.error_file_not_found);
+        } catch (final IOException e) {
+            throw new FileCopyException(R.string.error_io_exception);
+        }
+        tmp.renameTo(getFile(message));
+        updateFileParams(message, null, false);
+    }
+
+    public void setupRelativeFilePath(final Message message, final Uri uri, final String extension) throws FileCopyException {
+        try {
+            setupRelativeFilePath(message, mXmppConnectionService.getContentResolver().openInputStream(uri), extension);
+        } catch (final FileNotFoundException e) {
+            throw new FileCopyException(R.string.error_file_not_found);
+        } catch (final IOException e) {
+            throw new FileCopyException(R.string.error_io_exception);
+        }
+    }
+
+    public Cid[] calculateCids(final InputStream is) throws IOException {
+        try {
+            return CryptoHelper.cid(is, new String[]{"SHA-256", "SHA-1", "SHA-512"});
+        } catch (final NoSuchAlgorithmException e) {
+            throw new AssertionError(e);
+        }
+    }
+
+    public void setupRelativeFilePath(final Message message, final InputStream is, final String extension) throws IOException {
+        Cid[] cids = calculateCids(is);
+
+        setupRelativeFilePath(message, String.format("%s.%s", cids[0], extension));
+        File file = getFile(message);
+        for (int i = 0; i < cids.length; i++) {
+            mXmppConnectionService.saveCid(cids[i], file);
+        }
     }
 
     public void setupRelativeFilePath(final Message message, final String filename) {
@@ -1533,6 +1574,10 @@ public class FileBackend {
     }
 
     public void updateFileParams(final Message message, final String url) {
+        updateFileParams(message, url, true);
+    }
+
+    public void updateFileParams(final Message message, final String url, boolean updateCids) {
         final boolean encrypted =
                 message.getEncryption() == Message.ENCRYPTION_PGP
                         || message.getEncryption() == Message.ENCRYPTION_DECRYPTED;
@@ -1602,6 +1647,15 @@ public class FileBackend {
                 privateMessage
                         ? Message.TYPE_PRIVATE_FILE
                         : (image ? Message.TYPE_IMAGE : Message.TYPE_FILE));
+
+        if (updateCids) {
+            try {
+                Cid[] cids = calculateCids(new FileInputStream(getFile(message)));
+                for (int i = 0; i < cids.length; i++) {
+                    mXmppConnectionService.saveCid(cids[i], file);
+                }
+            } catch (final IOException e) { }
+        }
     }
 
     private int getMediaRuntime(final File file) {

src/main/java/eu/siacs/conversations/services/XmppConnectionService.java 🔗

@@ -86,6 +86,8 @@ import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.concurrent.atomic.AtomicLong;
 import java.util.concurrent.atomic.AtomicReference;
 
+import io.ipfs.cid.Cid;
+
 import eu.siacs.conversations.Config;
 import eu.siacs.conversations.R;
 import eu.siacs.conversations.android.JabberIdContact;
@@ -547,6 +549,14 @@ public class XmppConnectionService extends Service {
         return this.fileBackend;
     }
 
+    public File getFileForCid(Cid cid) {
+        return this.databaseBackend.getFileForCid(cid);
+    }
+
+    public void saveCid(Cid cid, File file) {
+        this.databaseBackend.saveCid(cid, file);
+    }
+
     public AvatarService getAvatarService() {
         return this.mAvatarService;
     }

src/main/java/eu/siacs/conversations/utils/CryptoHelper.java 🔗

@@ -9,6 +9,8 @@ import org.bouncycastle.asn1.x500.style.BCStyle;
 import org.bouncycastle.asn1.x500.style.IETFUtils;
 import org.bouncycastle.cert.jcajce.JcaX509CertificateHolder;
 
+import java.io.InputStream;
+import java.io.IOException;
 import java.nio.charset.StandardCharsets;
 import java.security.MessageDigest;
 import java.security.NoSuchAlgorithmException;
@@ -25,6 +27,9 @@ import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.regex.Pattern;
 
+import io.ipfs.cid.Cid;
+import io.ipfs.multihash.Multihash;
+
 import eu.siacs.conversations.Config;
 import eu.siacs.conversations.R;
 import eu.siacs.conversations.entities.Account;
@@ -282,4 +287,41 @@ public final class CryptoHelper {
         final String u = url.toLowerCase();
         return !u.contains(" ") && (u.startsWith("https://") || u.startsWith("http://") || u.startsWith("p1s3://")) && u.endsWith(".pgp");
     }
+
+    public static Multihash.Type multihashType(String algo) throws NoSuchAlgorithmException {
+        if (algo.equals("SHA-1") || algo.equals("sha-1") || algo.equals("sha1")) {
+            return Multihash.Type.sha1;
+        } else if (algo.equals("SHA-256") || algo.equals("sha-256")) {
+            return Multihash.Type.sha2_256;
+        } else if (algo.equals("SHA-512") | algo.equals("sha-512")) {
+            return Multihash.Type.sha2_512;
+        } else {
+            throw new NoSuchAlgorithmException(algo);
+        }
+    }
+
+    public static Cid cid(byte[] digest, String algo) throws NoSuchAlgorithmException {
+        return Cid.buildCidV1(Cid.Codec.Raw, multihashType(algo), digest);
+    }
+
+    public static Cid[] cid(InputStream in, String[] algo) throws NoSuchAlgorithmException, IOException {
+        byte[] buf = new byte[4096];
+        int len;
+        MessageDigest[] md = new MessageDigest[algo.length];
+        for (int i = 0; i < md.length; i++) {
+            md[i] = MessageDigest.getInstance(algo[i]);
+        }
+        while ((len = in.read(buf)) != -1) {
+            for (int i = 0; i < md.length; i++) {
+                md[i].update(buf, 0, len);
+            }
+        }
+
+        Cid[] cid = new Cid[md.length];
+        for (int i = 0; i < cid.length; i++) {
+            cid[i] = cid(md[i].digest(), algo[i]);
+        }
+
+        return cid;
+    }
 }

src/main/java/eu/siacs/conversations/xmpp/jingle/JingleFileTransferConnection.java 🔗

@@ -39,6 +39,7 @@ import eu.siacs.conversations.parser.IqParser;
 import eu.siacs.conversations.persistance.FileBackend;
 import eu.siacs.conversations.services.AbstractConnectionManager;
 import eu.siacs.conversations.utils.CryptoHelper;
+import eu.siacs.conversations.utils.MimeUtils;
 import eu.siacs.conversations.xml.Element;
 import eu.siacs.conversations.xml.Namespace;
 import eu.siacs.conversations.xmpp.Jid;
@@ -114,6 +115,8 @@ public class JingleFileTransferConnection extends AbstractJingleConnection imple
 
         @Override
         public void onFileTransmitted(DownloadableFile file) {
+            DownloadableFile finalFile;
+
             if (responding()) {
                 if (expectedHash.length > 0) {
                     if (Arrays.equals(expectedHash, file.getSha1Sum())) {
@@ -125,7 +128,17 @@ public class JingleFileTransferConnection extends AbstractJingleConnection imple
                     Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": other party did not include file hash in file transfer");
                 }
                 sendSuccess();
-                xmppConnectionService.getFileBackend().updateFileParams(message);
+
+                final String extension = MimeUtils.extractRelevantExtension(file.getName());
+                try {
+                    xmppConnectionService.getFileBackend().setupRelativeFilePath(message, new FileInputStream(file), extension);
+                    finalFile = xmppConnectionService.getFileBackend().getFile(message);
+                    file.renameTo(finalFile);
+                } catch (final IOException e) {
+                    finalFile = file;
+                }
+
+                xmppConnectionService.getFileBackend().updateFileParams(message, null, false);
                 xmppConnectionService.databaseBackend.createMessage(message);
                 xmppConnectionService.markMessage(message, Message.STATUS_RECEIVED);
                 if (acceptedAutomatically) {
@@ -142,6 +155,8 @@ public class JingleFileTransferConnection extends AbstractJingleConnection imple
                     id.account.getPgpDecryptionService().decrypt(message, true);
                 }
             } else {
+                finalFile = file;
+
                 if (description.getVersion() == FileTransferDescription.Version.FT_5) { //older Conversations will break when receiving a session-info
                     sendHash();
                 }
@@ -149,13 +164,13 @@ public class JingleFileTransferConnection extends AbstractJingleConnection imple
                     id.account.getPgpDecryptionService().decrypt(message, false);
                 }
                 if (message.getEncryption() == Message.ENCRYPTION_PGP || message.getEncryption() == Message.ENCRYPTION_DECRYPTED) {
-                    file.delete();
+                    finalFile.delete();
                 }
                 disconnectSocks5Connections();
             }
-            Log.d(Config.LOGTAG, "successfully transmitted file:" + file.getAbsolutePath() + " (" + CryptoHelper.bytesToHex(file.getSha1Sum()) + ")");
+            Log.d(Config.LOGTAG, "successfully transmitted file:" + finalFile.getAbsolutePath() + " (" + CryptoHelper.bytesToHex(file.getSha1Sum()) + ")");
             if (message.getEncryption() != Message.ENCRYPTION_PGP) {
-                xmppConnectionService.getFileBackend().updateMediaScanner(file);
+                xmppConnectionService.getFileBackend().updateMediaScanner(finalFile);
             }
         }