Detailed changes
  
  
    
    @@ -7,6 +7,7 @@
     <application tools:ignore="GoogleAppIndexingWarning">
         <!-- INSERT -->
 
+        <service android:name="com.cheogram.android.DownloadDefaultStickers" />
         <service android:name="com.cheogram.android.ConnectionService"
             android:label="Cheogram"
             android:permission="android.permission.BIND_TELECOM_CONNECTION_SERVICE"
  
  
  
    
    @@ -13,6 +13,7 @@ import java.net.URISyntaxException;
 import java.security.NoSuchAlgorithmException;
 
 import io.ipfs.cid.Cid;
+import io.ipfs.multihash.Multihash;
 
 import eu.siacs.conversations.Config;
 import eu.siacs.conversations.R;
@@ -57,7 +58,13 @@ public class BobTransfer implements Transferable {
 	}
 
 	public static URI uri(Cid cid) throws NoSuchAlgorithmException, URISyntaxException {
-		return new URI("cid", CryptoHelper.multihashAlgo(cid.getType()) + "+" + CryptoHelper.bytesToHex(cid.getHash()) + "@bob.xmpp.org", null);
+		return new URI("cid", multihashAlgo(cid.getType()) + "+" + CryptoHelper.bytesToHex(cid.getHash()) + "@bob.xmpp.org", null);
+	}
+
+	private static String multihashAlgo(Multihash.Type type) throws NoSuchAlgorithmException {
+		final String algo = CryptoHelper.multihashAlgo(type);
+		if (algo.equals("sha-1")) return "sha1";
+		return algo;
 	}
 
 	public BobTransfer(URI uri, Account account, Jid to, XmppConnectionService xmppConnectionService) {
  
  
  
    
    @@ -0,0 +1,178 @@
+package com.cheogram.android;
+
+import android.app.Notification;
+import android.app.NotificationManager;
+import android.app.Service;
+import android.content.Context;
+import android.content.SharedPreferences;
+import android.content.Intent;
+import android.database.Cursor;
+import android.media.MediaScannerConnection;
+import android.net.Uri;
+import android.os.Environment;
+import android.os.IBinder;
+import android.provider.DocumentsContract;
+import android.preference.PreferenceManager;
+import android.provider.MediaStore;
+import android.util.Log;
+
+import androidx.core.app.NotificationCompat;
+
+import com.google.common.io.ByteStreams;
+
+import io.ipfs.cid.Cid;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.OutputStream;
+import java.io.OutputStreamWriter;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+import org.json.JSONArray;
+import org.json.JSONObject;
+
+import okhttp3.OkHttpClient;
+import okhttp3.Request;
+import okhttp3.Response;
+
+import eu.siacs.conversations.Config;
+import eu.siacs.conversations.R;
+import eu.siacs.conversations.crypto.axolotl.SQLiteAxolotlStore;
+import eu.siacs.conversations.entities.Account;
+import eu.siacs.conversations.entities.Conversation;
+import eu.siacs.conversations.entities.Message;
+import eu.siacs.conversations.persistance.DatabaseBackend;
+import eu.siacs.conversations.persistance.FileBackend;
+import eu.siacs.conversations.utils.Compatibility;
+import eu.siacs.conversations.utils.FileUtils;
+import eu.siacs.conversations.utils.MimeUtils;
+
+public class DownloadDefaultStickers extends Service {
+
+	private static final int NOTIFICATION_ID = 20;
+	private static final AtomicBoolean RUNNING = new AtomicBoolean(false);
+	private DatabaseBackend mDatabaseBackend;
+	private NotificationManager notificationManager;
+	private File mStickerDir;
+	private OkHttpClient http = new OkHttpClient();
+
+	@Override
+	public void onCreate() {
+		mDatabaseBackend = DatabaseBackend.getInstance(getBaseContext());
+		notificationManager = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE);
+		mStickerDir = stickerDir();
+	}
+
+	@Override
+	public int onStartCommand(Intent intent, int flags, int startId) {
+		if (RUNNING.compareAndSet(false, true)) {
+			new Thread(() -> {
+				try {
+					download();
+				} catch (final Exception e) {
+					Log.d(Config.LOGTAG, "unable to download stickers", e);
+				}
+				stopForeground(true);
+				RUNNING.set(false);
+				stopSelf();
+			}).start();
+			return START_STICKY;
+		} else {
+			Log.d(Config.LOGTAG, "DownloadDefaultStickers. ignoring start command because already running");
+		}
+		return START_NOT_STICKY;
+	}
+
+	private void oneSticker(JSONObject sticker) throws Exception {
+		Response r = http.newCall(new Request.Builder().url(sticker.getString("url")).build()).execute();
+		File file = new File(mStickerDir.getAbsolutePath() + "/" + sticker.getString("pack") + "/" + sticker.getString("name") + "." + MimeUtils.guessExtensionFromMimeType(r.headers().get("content-type")));
+		file.getParentFile().mkdirs();
+		OutputStream os = new FileOutputStream(file);
+		ByteStreams.copy(r.body().byteStream(), os);
+		os.close();
+
+		JSONArray cids = sticker.getJSONArray("cids");
+		for (int i = 0; i < cids.length(); i++) {
+			Cid cid = Cid.decode(cids.getString(i));
+			mDatabaseBackend.saveCid(cid, file, sticker.getString("url"));
+		}
+
+		MediaScannerConnection.scanFile(
+			getBaseContext(),
+			new String[] { file.getAbsolutePath() },
+			null,
+			new MediaScannerConnection.MediaScannerConnectionClient() {
+				@Override
+				public void onMediaScannerConnected() {}
+
+				@Override
+				public void onScanCompleted(String path, Uri uri) {}
+			}
+		);
+
+		try {
+			File copyright = new File(mStickerDir.getAbsolutePath() + "/" + sticker.getString("pack") + "/copyright.txt");
+			OutputStreamWriter w = new OutputStreamWriter(new FileOutputStream(copyright, true), "utf-8");
+			w.write(sticker.getString("pack"));
+			w.write('/');
+			w.write(sticker.getString("name"));
+			w.write(": ");
+			w.write(sticker.getString("copyright"));
+			w.write('\n');
+			w.close();
+		} catch (final Exception e) { }
+	}
+
+	private void download() throws Exception {
+		NotificationCompat.Builder mBuilder = new NotificationCompat.Builder(getBaseContext(), "backup");
+		mBuilder.setContentTitle("Downloading Default Stickers")
+				.setSmallIcon(R.drawable.ic_archive_white_24dp)
+				.setProgress(1, 0, false);
+		startForeground(NOTIFICATION_ID, mBuilder.build());
+
+		Response r = http.newCall(new Request.Builder().url("https://stickers.cheogram.com/index.json").build()).execute();
+		JSONArray stickers = new JSONArray(r.body().string());
+
+		final Progress progress = new Progress(mBuilder, 1, 0);
+		for (int i = 0; i < stickers.length(); i++) {
+			oneSticker(stickers.getJSONObject(i));
+
+			final int percentage = i * 100 / stickers.length();
+			notificationManager.notify(NOTIFICATION_ID, progress.build(percentage));
+		}
+	}
+
+	private File stickerDir() {
+		SharedPreferences p = PreferenceManager.getDefaultSharedPreferences(getBaseContext());
+		final String dir = p.getString("sticker_directory", "Stickers");
+		if (dir.startsWith("content://")) {
+			Uri uri = Uri.parse(dir);
+			uri = DocumentsContract.buildDocumentUriUsingTree(uri, DocumentsContract.getTreeDocumentId(uri));
+			return new File(FileUtils.getPath(getBaseContext(), uri));
+		} else {
+			return new File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES) + "/" + dir);
+		}
+	}
+
+	@Override
+	public IBinder onBind(Intent intent) {
+		return null;
+	}
+
+	private static class Progress {
+		private final NotificationCompat.Builder builder;
+		private final int max;
+		private final int count;
+
+		private Progress(NotificationCompat.Builder builder, int max, int count) {
+			this.builder = builder;
+			this.max = max;
+			this.count = count;
+		}
+
+		private Notification build(int percentage) {
+			builder.setProgress(max * 100, count * 100 + percentage, false);
+			return builder.build();
+		}
+	}
+}
  
  
  
    
    @@ -1100,6 +1100,9 @@ public class Message extends AbstractEntity implements AvatarService.Avatarable
             fileParams.sims = this.fileParams.sims;
         }
         this.fileParams = fileParams;
+        if (fileParams != null && getSims().isEmpty()) {
+            addPayload(fileParams.toSims());
+        }
     }
 
     public synchronized FileParams getFileParams() {
@@ -1229,6 +1232,21 @@ public class Message extends AbstractEntity implements AvatarService.Avatarable
             return file.findChildContent("name", file.getNamespace());
         }
 
+        public void setName(final String name) {
+            if (sims == null) toSims();
+            Element file = getFileElement();
+
+            for (Element child : file.getChildren()) {
+                if (child.getName().equals("name") && child.getNamespace().equals(file.getNamespace())) {
+                    file.removeChild(child);
+                }
+            }
+
+            if (name != null) {
+                file.addChild("name", file.getNamespace()).setContent(name);
+            }
+        }
+
         public Element toSims() {
             if (sims == null) sims = new Element("reference", "urn:xmpp:reference:0");
             sims.setAttribute("type", "data");
@@ -1277,6 +1295,23 @@ public class Message extends AbstractEntity implements AvatarService.Avatarable
             return file;
         }
 
+        public void setCids(Iterable<Cid> cids) throws NoSuchAlgorithmException {
+            if (sims == null) toSims();
+            Element file = getFileElement();
+
+            for (Element child : file.getChildren()) {
+                if (child.getName().equals("hash") && child.getNamespace().equals("urn:xmpp:hashes:2")) {
+                    file.removeChild(child);
+                }
+            }
+
+            for (Cid cid : cids) {
+                file.addChild("hash", "urn:xmpp:hashes:2")
+                    .setAttribute("algo", CryptoHelper.multihashAlgo(cid.getType()))
+                    .setContent(Base64.encodeToString(cid.getHash(), Base64.NO_WRAP));
+            }
+        }
+
         public List<Cid> getCids() {
             List<Cid> cids = new ArrayList<>();
             Element file = getFileElement();
  
  
  
    
    @@ -283,6 +283,14 @@ public class DatabaseBackend extends SQLiteOpenHelper {
                 db.execSQL("PRAGMA cheogram.user_version = 6");
             }
 
+            if(cheogramVersion < 7) {
+                db.execSQL(
+                    "ALTER TABLE cheogram.cids " +
+                    "ADD COLUMN url TEXT"
+                );
+                db.execSQL("PRAGMA cheogram.user_version = 7");
+            }
+
             db.setTransactionSuccessful();
         } finally {
             db.endTransaction();
@@ -775,11 +783,27 @@ public class DatabaseBackend extends SQLiteOpenHelper {
         return f;
     }
 
+    public String getUrlForCid(Cid cid) {
+        SQLiteDatabase db = this.getReadableDatabase();
+        Cursor cursor = db.query("cheogram.cids", new String[]{"url"}, "cid=?", new String[]{cid.toString()}, null, null, null);
+        String url = null;
+        if (cursor.moveToNext()) {
+            url = cursor.getString(0);
+        }
+        cursor.close();
+        return url;
+    }
+
     public void saveCid(Cid cid, File file) {
+        saveCid(cid, file, null);
+    }
+
+    public void saveCid(Cid cid, File file, String url) {
         SQLiteDatabase db = this.getWritableDatabase();
         ContentValues cv = new ContentValues();
         cv.put("cid", cid.toString());
         cv.put("path", file.getAbsolutePath());
+        cv.put("url", url);
         db.insertWithOnConflict("cheogram.cids", null, cv, SQLiteDatabase.CONFLICT_REPLACE);
     }
 
  
  
  
    
    @@ -654,6 +654,9 @@ public class FileBackend {
         BitmapFactory.Options options = new BitmapFactory.Options();
         options.inJustDecodeBounds = true;
         try {
+            for (Cid cid : calculateCids(uri)) {
+                if (mXmppConnectionService.getUrlForCid(cid) != null) return true;
+            }
             final InputStream inputStream =
                     mXmppConnectionService.getContentResolver().openInputStream(uri);
             BitmapFactory.decodeStream(inputStream, null, options);
@@ -664,7 +667,7 @@ public class FileBackend {
             return (options.outWidth <= Config.IMAGE_SIZE
                     && options.outHeight <= Config.IMAGE_SIZE
                     && options.outMimeType.contains(Config.IMAGE_FORMAT.name().toLowerCase()));
-        } catch (FileNotFoundException e) {
+        } catch (final IOException e) {
             Log.d(Config.LOGTAG, "unable to get image dimensions", e);
             return false;
         }
@@ -927,6 +930,10 @@ public class FileBackend {
         }
     }
 
+    public Cid[] calculateCids(final Uri uri) throws IOException {
+        return calculateCids(mXmppConnectionService.getContentResolver().openInputStream(uri));
+    }
+
     public Cid[] calculateCids(final InputStream is) throws IOException {
         try {
             return CryptoHelper.cid(is, new String[]{"SHA-256", "SHA-1", "SHA-512"});
@@ -1727,7 +1734,7 @@ public class FileBackend {
         updateFileParams(message, url, true);
     }
 
-    public void updateFileParams(final Message message, final String url, boolean updateCids) {
+    public void updateFileParams(final Message message, String url, boolean updateCids) {
         final boolean encrypted =
                 message.getEncryption() == Message.ENCRYPTION_PGP
                         || message.getEncryption() == Message.ENCRYPTION_DECRYPTED;
@@ -1739,9 +1746,23 @@ public class FileBackend {
                         || (mime != null && mime.startsWith("image/"));
         Message.FileParams fileParams = message.getFileParams();
         if (fileParams == null) fileParams = new Message.FileParams();
-        if (url != null) {
+        Cid[] cids = new Cid[0];
+        try {
+            cids = calculateCids(new FileInputStream(file));
+            fileParams.setCids(List.of(cids));
+        } catch (final IOException | NoSuchAlgorithmException e) { }
+        if (url == null) {
+            for (Cid cid : cids) {
+                url = mXmppConnectionService.getUrlForCid(cid);
+                if (url != null) {
+                    fileParams.url = url;
+                    break;
+                }
+            }
+        } else {
             fileParams.url = url;
         }
+        fileParams.setName(file.getName());
         if (encrypted && !file.exists()) {
             Log.d(Config.LOGTAG, "skipping updateFileParams because file is encrypted");
             final DownloadableFile encryptedFile = getFile(message, false);
@@ -1801,11 +1822,10 @@ public class FileBackend {
 
         if (updateCids) {
             try {
-                Cid[] cids = calculateCids(new FileInputStream(getFile(message)));
                 for (int i = 0; i < cids.length; i++) {
-                    mXmppConnectionService.saveCid(cids[i], file);
+                    mXmppConnectionService.saveCid(cids[i], file, url);
                 }
-            } catch (final IOException | XmppConnectionService.BlockedMediaException e) { }
+            } catch (XmppConnectionService.BlockedMediaException e) { }
         }
     }
 
  
  
  
    
    @@ -561,11 +561,19 @@ public class XmppConnectionService extends Service {
         return this.databaseBackend.getFileForCid(cid);
     }
 
+    public String getUrlForCid(Cid cid) {
+        return this.databaseBackend.getUrlForCid(cid);
+    }
+
     public void saveCid(Cid cid, File file) throws BlockedMediaException {
+        saveCid(cid, file, null);
+    }
+
+    public void saveCid(Cid cid, File file, String url) throws BlockedMediaException {
         if (this.databaseBackend.isBlockedMedia(cid)) {
             throw new BlockedMediaException();
         }
-        this.databaseBackend.saveCid(cid, file);
+        this.databaseBackend.saveCid(cid, file, url);
     }
 
     public void blockMedia(File f) {
@@ -1610,6 +1618,9 @@ public class XmppConnectionService extends Service {
 
         final boolean inProgressJoin = isJoinInProgress(conversation);
 
+        if (message.getCounterpart() == null && !message.isPrivateMessage()) {
+            message.setCounterpart(message.getConversation().getJid().asBareJid());
+        }
 
         if (account.isOnlineAndConnected() && !inProgressJoin) {
             switch (message.getEncryption()) {
  
  
  
    
    @@ -57,8 +57,11 @@ import androidx.annotation.IdRes;
 import androidx.annotation.NonNull;
 import androidx.appcompat.app.ActionBar;
 import androidx.appcompat.app.AlertDialog;
+import androidx.core.content.ContextCompat;
 import androidx.databinding.DataBindingUtil;
 
+import com.cheogram.android.DownloadDefaultStickers;
+
 import org.openintents.openpgp.util.OpenPgpApi;
 
 import java.util.Arrays;
@@ -118,6 +121,7 @@ public class ConversationsActivity extends XmppActivity implements OnConversatio
     public static final int REQUEST_PLAY_PAUSE = 0x5432;
     public static final int REQUEST_MICROPHONE = 0x5432f;
     public static final int DIALLER_INTEGRATION = 0x5432ff;
+    public static final int REQUEST_DOWNLOAD_STICKERS = 0xbf8702;
 
 
     //secondary fragment (when holding the conversation, must be initialized before refreshing the overview fragment
@@ -217,12 +221,10 @@ public class ConversationsActivity extends XmppActivity implements OnConversatio
         }
         final Fragment fragment = getFragmentManager().findFragmentById(R.id.main_fragment);
         if (fragment instanceof ConversationsOverviewFragment) {
-            if (ExceptionHelper.checkForCrash(this)) {
-                return;
-            }
-            if (!offerToSetupDiallerIntegration()) {
-                openBatteryOptimizationDialogIfNeeded();
-            }
+            if (ExceptionHelper.checkForCrash(this)) return;
+            if (offerToSetupDiallerIntegration()) return;
+            if (offerToDownloadStickers()) return;
+            openBatteryOptimizationDialogIfNeeded();
         }
     }
 
@@ -259,6 +261,26 @@ public class ConversationsActivity extends XmppActivity implements OnConversatio
         }
     }
 
+    private boolean offerToDownloadStickers() {
+        int offered = getPreferences().getInt("default_stickers_offered", 0);
+        if (offered > 0) return false;
+        getPreferences().edit().putInt("default_stickers_offered", 1).apply();
+
+        AlertDialog.Builder builder = new AlertDialog.Builder(this);
+        builder.setTitle("Download Stickers?");
+        builder.setMessage("Would you like to download some default sticker packs?");
+        builder.setPositiveButton(R.string.yes, (dialog, which) -> {
+            if (hasStoragePermission(REQUEST_DOWNLOAD_STICKERS)) {
+                downloadStickers();
+            }
+        });
+        builder.setNegativeButton(R.string.no, (dialog, which) -> { });
+        final AlertDialog dialog = builder.create();
+        dialog.setCanceledOnTouchOutside(false);
+        dialog.show();
+        return true;
+    }
+
     private boolean offerToSetupDiallerIntegration() {
         if (mRequestCode == DIALLER_INTEGRATION) {
             mRequestCode = -1;
@@ -353,11 +375,21 @@ public class ConversationsActivity extends XmppActivity implements OnConversatio
                             "com.android.server.telecom.settings.EnableAccountPreferenceActivity"));
                         startActivityForResult(intent, DIALLER_INTEGRATION);
                         break;
+                    case REQUEST_DOWNLOAD_STICKERS:
+                        downloadStickers();
+                        break;
                 }
             }
         }
     }
 
+    private void downloadStickers() {
+        Intent intent = new Intent(this, DownloadDefaultStickers.class);
+        ContextCompat.startForegroundService(this, intent);
+        displayToast("Sticker download started");
+        showDialogsIfMainIsOverview();
+    }
+
     @Override
     public void onActivityResult(int requestCode, int resultCode, final Intent data) {
         super.onActivityResult(requestCode, resultCode, data);
  
  
  
    
    @@ -25,6 +25,8 @@ import androidx.annotation.NonNull;
 import androidx.appcompat.app.AlertDialog;
 import androidx.core.content.ContextCompat;
 
+import com.cheogram.android.DownloadDefaultStickers;
+
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Lists;
@@ -69,6 +71,7 @@ public class SettingsActivity extends XmppActivity implements OnSharedPreference
     public static final String PREVENT_SCREENSHOTS = "prevent_screenshots";
 
     public static final int REQUEST_CREATE_BACKUP = 0xbf8701;
+    public static final int REQUEST_DOWNLOAD_STICKERS = 0xbf8702;
 
     private SettingsFragment mSettingsFragment;
 
@@ -390,6 +393,17 @@ public class SettingsActivity extends XmppActivity implements OnSharedPreference
             }
         }
 
+        final Preference downloadDefaultStickers = mSettingsFragment.findPreference("download_default_stickers");
+        if (downloadDefaultStickers != null) {
+            downloadDefaultStickers.setOnPreferenceClickListener(
+                    preference -> {
+                        if (hasStoragePermission(REQUEST_DOWNLOAD_STICKERS)) {
+                            downloadStickers();
+                        }
+                        return true;
+                    });
+        }
+
         final Preference clearBlockedMedia = mSettingsFragment.findPreference("clear_blocked_media");
         if (clearBlockedMedia != null) {
             clearBlockedMedia.setOnPreferenceClickListener((p) -> {
@@ -587,6 +601,9 @@ public class SettingsActivity extends XmppActivity implements OnSharedPreference
                 if (requestCode == REQUEST_CREATE_BACKUP) {
                     createBackup();
                 }
+                if (requestCode == REQUEST_DOWNLOAD_STICKERS) {
+                    downloadStickers();
+                }
             } else {
                 Toast.makeText(
                                 this,
@@ -620,6 +637,12 @@ public class SettingsActivity extends XmppActivity implements OnSharedPreference
         builder.create().show();
     }
 
+    private void downloadStickers() {
+        Intent intent = new Intent(this, DownloadDefaultStickers.class);
+        ContextCompat.startForegroundService(this, intent);
+        displayToast("Sticker download started");
+    }
+
     private void displayToast(final String msg) {
         runOnUiThread(() -> Toast.makeText(SettingsActivity.this, msg, Toast.LENGTH_LONG).show());
     }
  
  
  
    
    @@ -293,7 +293,7 @@ public final class CryptoHelper {
     public static String multihashAlgo(Multihash.Type type) throws NoSuchAlgorithmException {
         switch(type) {
         case sha1:
-            return "sha1";
+            return "sha-1";
         case sha2_256:
             return "sha-256";
         case sha2_512:
  
  
  
    
    @@ -370,6 +370,9 @@
                 <Preference
                     android:title="Change Stickers Location"
                     android:key="sticker_directory" />
+                <Preference
+                    android:title="Update Default Stickers"
+                    android:key="download_default_stickers" />
                 <Preference
                     android:title="Clear Blocked Media"
                     android:key="clear_blocked_media" />