FileBackend.java

   1package eu.siacs.conversations.persistance;
   2
   3import android.content.ContentResolver;
   4import android.content.Context;
   5import android.content.res.AssetFileDescriptor;
   6import android.content.res.Resources;
   7import android.database.Cursor;
   8import android.graphics.Bitmap;
   9import android.graphics.BitmapFactory;
  10import android.graphics.Canvas;
  11import android.graphics.Color;
  12import android.graphics.drawable.BitmapDrawable;
  13import android.graphics.drawable.Drawable;
  14import android.graphics.ImageDecoder;
  15import android.graphics.Matrix;
  16import android.graphics.Paint;
  17import android.graphics.Rect;
  18import android.graphics.RectF;
  19import android.graphics.pdf.PdfRenderer;
  20import android.media.MediaMetadataRetriever;
  21import android.media.MediaScannerConnection;
  22import android.net.Uri;
  23import android.os.Build;
  24import android.os.Environment;
  25import android.os.ParcelFileDescriptor;
  26import android.provider.MediaStore;
  27import android.provider.OpenableColumns;
  28import android.system.Os;
  29import android.system.StructStat;
  30import android.util.Base64;
  31import android.util.Base64OutputStream;
  32import android.util.DisplayMetrics;
  33import android.util.Pair;
  34import android.util.Log;
  35import android.util.LruCache;
  36
  37import androidx.annotation.RequiresApi;
  38import androidx.annotation.StringRes;
  39import androidx.core.content.FileProvider;
  40import androidx.documentfile.provider.DocumentFile;
  41import androidx.exifinterface.media.ExifInterface;
  42
  43import com.caverock.androidsvg.SVG;
  44import com.caverock.androidsvg.SVGParseException;
  45
  46import com.cheogram.android.BobTransfer;
  47
  48import com.google.common.base.Strings;
  49import com.google.common.collect.ImmutableList;
  50import com.google.common.io.ByteStreams;
  51
  52import com.madebyevan.thumbhash.ThumbHash;
  53
  54import com.wolt.blurhashkt.BlurHashDecoder;
  55
  56import java.io.ByteArrayOutputStream;
  57import java.io.Closeable;
  58import java.io.File;
  59import java.io.FileDescriptor;
  60import java.io.FileInputStream;
  61import java.io.FileNotFoundException;
  62import java.io.FileOutputStream;
  63import java.io.IOException;
  64import java.io.InputStream;
  65import java.io.OutputStream;
  66import java.net.ServerSocket;
  67import java.net.Socket;
  68import java.nio.ByteBuffer;
  69import java.security.DigestOutputStream;
  70import java.security.MessageDigest;
  71import java.security.NoSuchAlgorithmException;
  72import java.text.SimpleDateFormat;
  73import java.util.ArrayList;
  74import java.util.Arrays;
  75import java.util.Date;
  76import java.util.List;
  77import java.util.Locale;
  78import java.util.UUID;
  79
  80import io.ipfs.cid.Cid;
  81
  82import eu.siacs.conversations.Config;
  83import eu.siacs.conversations.R;
  84import eu.siacs.conversations.entities.DownloadableFile;
  85import eu.siacs.conversations.entities.Message;
  86import eu.siacs.conversations.services.AttachFileToConversationRunnable;
  87import eu.siacs.conversations.services.XmppConnectionService;
  88import eu.siacs.conversations.ui.adapter.MediaAdapter;
  89import eu.siacs.conversations.ui.util.Attachment;
  90import eu.siacs.conversations.utils.CryptoHelper;
  91import eu.siacs.conversations.utils.FileUtils;
  92import eu.siacs.conversations.utils.FileWriterException;
  93import eu.siacs.conversations.utils.MimeUtils;
  94import eu.siacs.conversations.xmpp.pep.Avatar;
  95import eu.siacs.conversations.xml.Element;
  96
  97public class FileBackend {
  98
  99    private static final Object THUMBNAIL_LOCK = new Object();
 100
 101    private static final SimpleDateFormat IMAGE_DATE_FORMAT =
 102            new SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US);
 103
 104    private static final String FILE_PROVIDER = ".files";
 105    private static final float IGNORE_PADDING = 0.15f;
 106    private final XmppConnectionService mXmppConnectionService;
 107
 108    private static final List<String> STORAGE_TYPES;
 109
 110    static {
 111        final ImmutableList.Builder<String> builder =
 112                new ImmutableList.Builder<String>()
 113                        .add(
 114                                Environment.DIRECTORY_DOWNLOADS,
 115                                Environment.DIRECTORY_PICTURES,
 116                                Environment.DIRECTORY_MOVIES);
 117        if (Build.VERSION.SDK_INT >= 19) {
 118            builder.add(Environment.DIRECTORY_DOCUMENTS);
 119        }
 120        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
 121            builder.add(Environment.DIRECTORY_RECORDINGS);
 122        }
 123        STORAGE_TYPES = builder.build();
 124    }
 125
 126    public FileBackend(XmppConnectionService service) {
 127        this.mXmppConnectionService = service;
 128    }
 129
 130    public static long getFileSize(Context context, Uri uri) {
 131        try (final Cursor cursor =
 132                context.getContentResolver().query(uri, null, null, null, null)) {
 133            if (cursor != null && cursor.moveToFirst()) {
 134                final int index = cursor.getColumnIndex(OpenableColumns.SIZE);
 135                if (index == -1) {
 136                    return -1;
 137                }
 138                return cursor.getLong(index);
 139            }
 140            return -1;
 141        } catch (final Exception ignored) {
 142            return -1;
 143        }
 144    }
 145
 146    public static boolean allFilesUnderSize(
 147            Context context, List<Attachment> attachments, long max) {
 148        final boolean compressVideo =
 149                !AttachFileToConversationRunnable.getVideoCompression(context)
 150                        .equals("uncompressed");
 151        if (max <= 0) {
 152            Log.d(Config.LOGTAG, "server did not report max file size for http upload");
 153            return true; // exception to be compatible with HTTP Upload < v0.2
 154        }
 155        for (Attachment attachment : attachments) {
 156            if (attachment.getType() != Attachment.Type.FILE) {
 157                continue;
 158            }
 159            String mime = attachment.getMime();
 160            if (mime != null && mime.startsWith("video/") && compressVideo) {
 161                try {
 162                    Dimensions dimensions =
 163                            FileBackend.getVideoDimensions(context, attachment.getUri());
 164                    if (dimensions.getMin() > 720) {
 165                        Log.d(
 166                                Config.LOGTAG,
 167                                "do not consider video file with min width larger than 720 for size check");
 168                        continue;
 169                    }
 170                } catch (final IOException | NotAVideoFile e) {
 171                    // ignore and fall through
 172                }
 173            }
 174            if (FileBackend.getFileSize(context, attachment.getUri()) > max) {
 175                Log.d(
 176                        Config.LOGTAG,
 177                        "not all files are under "
 178                                + max
 179                                + " bytes. suggesting falling back to jingle");
 180                return false;
 181            }
 182        }
 183        return true;
 184    }
 185
 186    public static File getBackupDirectory(final Context context) {
 187        final File conversationsDownloadDirectory =
 188                new File(
 189                        Environment.getExternalStoragePublicDirectory(
 190                                Environment.DIRECTORY_DOWNLOADS),
 191                        context.getString(R.string.app_name));
 192        return new File(conversationsDownloadDirectory, "Backup");
 193    }
 194
 195    public static File getLegacyBackupDirectory(final String app) {
 196        final File appDirectory = new File(Environment.getExternalStorageDirectory(), app);
 197        return new File(appDirectory, "Backup");
 198    }
 199
 200    private static Bitmap rotate(final Bitmap bitmap, final int degree) {
 201        if (degree == 0) {
 202            return bitmap;
 203        }
 204        final int w = bitmap.getWidth();
 205        final int h = bitmap.getHeight();
 206        final Matrix matrix = new Matrix();
 207        matrix.postRotate(degree);
 208        final Bitmap result = Bitmap.createBitmap(bitmap, 0, 0, w, h, matrix, true);
 209        if (!bitmap.isRecycled()) {
 210            bitmap.recycle();
 211        }
 212        return result;
 213    }
 214
 215    public static boolean isPathBlacklisted(String path) {
 216        final String androidDataPath =
 217                Environment.getExternalStorageDirectory().getAbsolutePath() + "/Android/data/";
 218        final File f = new File(path);
 219        return path.startsWith(androidDataPath) || !f.canRead();
 220    }
 221
 222    private static Paint createAntiAliasingPaint() {
 223        Paint paint = new Paint();
 224        paint.setAntiAlias(true);
 225        paint.setFilterBitmap(true);
 226        paint.setDither(true);
 227        return paint;
 228    }
 229
 230    public static Uri getUriForUri(Context context, Uri uri) {
 231        if ("file".equals(uri.getScheme())) {
 232            return getUriForFile(context, new File(uri.getPath()));
 233        } else {
 234            return uri;
 235        }
 236    }
 237
 238    public static Uri getUriForFile(Context context, File file) {
 239        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N || Config.ONLY_INTERNAL_STORAGE) {
 240            try {
 241                return FileProvider.getUriForFile(context, getAuthority(context), file);
 242            } catch (IllegalArgumentException e) {
 243                if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
 244                    throw new SecurityException(e);
 245                } else {
 246                    return Uri.fromFile(file);
 247                }
 248            }
 249        } else {
 250            return Uri.fromFile(file);
 251        }
 252    }
 253
 254    public static String getAuthority(Context context) {
 255        return context.getPackageName() + FILE_PROVIDER;
 256    }
 257
 258    private static boolean hasAlpha(final Bitmap bitmap) {
 259        final int w = bitmap.getWidth();
 260        final int h = bitmap.getHeight();
 261        final int yStep = Math.max(1, w / 100);
 262        final int xStep = Math.max(1, h / 100);
 263        for (int x = 0; x < w; x += xStep) {
 264            for (int y = 0; y < h; y += yStep) {
 265                if (Color.alpha(bitmap.getPixel(x, y)) < 255) {
 266                    return true;
 267                }
 268            }
 269        }
 270        return false;
 271    }
 272
 273    private static int calcSampleSize(File image, int size) {
 274        BitmapFactory.Options options = new BitmapFactory.Options();
 275        options.inJustDecodeBounds = true;
 276        BitmapFactory.decodeFile(image.getAbsolutePath(), options);
 277        return calcSampleSize(options, size);
 278    }
 279
 280    private static int calcSampleSize(BitmapFactory.Options options, int size) {
 281        int height = options.outHeight;
 282        int width = options.outWidth;
 283        int inSampleSize = 1;
 284
 285        if (height > size || width > size) {
 286            int halfHeight = height / 2;
 287            int halfWidth = width / 2;
 288
 289            while ((halfHeight / inSampleSize) > size && (halfWidth / inSampleSize) > size) {
 290                inSampleSize *= 2;
 291            }
 292        }
 293        return inSampleSize;
 294    }
 295
 296    private static Dimensions getVideoDimensions(Context context, Uri uri) throws NotAVideoFile, IOException {
 297        MediaMetadataRetriever mediaMetadataRetriever = new MediaMetadataRetriever();
 298        try {
 299            mediaMetadataRetriever.setDataSource(context, uri);
 300        } catch (RuntimeException e) {
 301            throw new NotAVideoFile(e);
 302        }
 303        return getVideoDimensions(mediaMetadataRetriever);
 304    }
 305
 306    private static Dimensions getVideoDimensionsOfFrame(
 307            MediaMetadataRetriever mediaMetadataRetriever) {
 308        Bitmap bitmap = null;
 309        try {
 310            bitmap = mediaMetadataRetriever.getFrameAtTime();
 311            return new Dimensions(bitmap.getHeight(), bitmap.getWidth());
 312        } catch (Exception e) {
 313            return null;
 314        } finally {
 315            if (bitmap != null) {
 316                bitmap.recycle();
 317            }
 318        }
 319    }
 320
 321    private static Dimensions getVideoDimensions(MediaMetadataRetriever metadataRetriever)
 322            throws NotAVideoFile, IOException {
 323        String hasVideo =
 324                metadataRetriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_HAS_VIDEO);
 325        if (hasVideo == null) {
 326            throw new NotAVideoFile();
 327        }
 328        Dimensions dimensions = getVideoDimensionsOfFrame(metadataRetriever);
 329        if (dimensions != null) {
 330            return dimensions;
 331        }
 332        final int rotation = extractRotationFromMediaRetriever(metadataRetriever);
 333        boolean rotated = rotation == 90 || rotation == 270;
 334        int height;
 335        try {
 336            String h =
 337                    metadataRetriever.extractMetadata(
 338                            MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT);
 339            height = Integer.parseInt(h);
 340        } catch (Exception e) {
 341            height = -1;
 342        }
 343        int width;
 344        try {
 345            String w =
 346                    metadataRetriever.extractMetadata(
 347                            MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH);
 348            width = Integer.parseInt(w);
 349        } catch (Exception e) {
 350            width = -1;
 351        }
 352        try {
 353            metadataRetriever.release();
 354        } catch (final IOException e) {
 355            throw new NotAVideoFile();
 356        }
 357        Log.d(Config.LOGTAG, "extracted video dims " + width + "x" + height);
 358        return rotated ? new Dimensions(width, height) : new Dimensions(height, width);
 359    }
 360
 361    private static int extractRotationFromMediaRetriever(MediaMetadataRetriever metadataRetriever) {
 362        String r =
 363                metadataRetriever.extractMetadata(
 364                        MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION);
 365        try {
 366            return Integer.parseInt(r);
 367        } catch (Exception e) {
 368            return 0;
 369        }
 370    }
 371
 372    public static void close(final Closeable stream) {
 373        if (stream != null) {
 374            try {
 375                stream.close();
 376            } catch (Exception e) {
 377                Log.d(Config.LOGTAG, "unable to close stream", e);
 378            }
 379        }
 380    }
 381
 382    public static void close(final Socket socket) {
 383        if (socket != null) {
 384            try {
 385                socket.close();
 386            } catch (IOException e) {
 387                Log.d(Config.LOGTAG, "unable to close socket", e);
 388            }
 389        }
 390    }
 391
 392    public static void close(final ServerSocket socket) {
 393        if (socket != null) {
 394            try {
 395                socket.close();
 396            } catch (IOException e) {
 397                Log.d(Config.LOGTAG, "unable to close server socket", e);
 398            }
 399        }
 400    }
 401
 402    public static boolean weOwnFile(final Uri uri) {
 403        if (uri == null || !ContentResolver.SCHEME_FILE.equals(uri.getScheme())) {
 404            return false;
 405        } else {
 406            return weOwnFileLollipop(uri);
 407        }
 408    }
 409
 410    private static boolean weOwnFileLollipop(final Uri uri) {
 411        final String path = uri.getPath();
 412        if (path == null) {
 413            return false;
 414        }
 415        try {
 416            File file = new File(path);
 417            FileDescriptor fd =
 418                    ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY)
 419                            .getFileDescriptor();
 420            StructStat st = Os.fstat(fd);
 421            return st.st_uid == android.os.Process.myUid();
 422        } catch (FileNotFoundException e) {
 423            return false;
 424        } catch (Exception e) {
 425            return true;
 426        }
 427    }
 428
 429    public static Uri getMediaUri(Context context, File file) {
 430        final String filePath = file.getAbsolutePath();
 431        try (final Cursor cursor =
 432                context.getContentResolver()
 433                        .query(
 434                                MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
 435                                new String[] {MediaStore.Images.Media._ID},
 436                                MediaStore.Images.Media.DATA + "=? ",
 437                                new String[] {filePath},
 438                                null)) {
 439            if (cursor != null && cursor.moveToFirst()) {
 440                final int id =
 441                        cursor.getInt(cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID));
 442                return Uri.withAppendedPath(
 443                        MediaStore.Images.Media.EXTERNAL_CONTENT_URI, String.valueOf(id));
 444            } else {
 445                return null;
 446            }
 447        } catch (final Exception e) {
 448            return null;
 449        }
 450    }
 451
 452    public static void updateFileParams(Message message, String url, long size) {
 453        Message.FileParams fileParams = new Message.FileParams();
 454        fileParams.url = url;
 455        fileParams.size = size;
 456        message.setFileParams(fileParams);
 457    }
 458
 459    public Bitmap getPreviewForUri(Attachment attachment, int size, boolean cacheOnly) {
 460        final String key = "attachment_" + attachment.getUuid().toString() + "_" + size;
 461        final LruCache<String, Drawable> cache = mXmppConnectionService.getDrawableCache();
 462        Drawable drawable = cache.get(key);
 463        if (drawable != null || cacheOnly) {
 464            return drawDrawable(drawable);
 465        }
 466        Bitmap bitmap = null;
 467        final String mime = attachment.getMime();
 468        if ("application/pdf".equals(mime)) {
 469            bitmap = cropCenterSquarePdf(attachment.getUri(), size);
 470            drawOverlay(
 471                    bitmap,
 472                    paintOverlayBlackPdf(bitmap)
 473                            ? R.drawable.open_pdf_black
 474                            : R.drawable.open_pdf_white,
 475                    0.75f);
 476        } else if (mime != null && mime.startsWith("video/")) {
 477            bitmap = cropCenterSquareVideo(attachment.getUri(), size);
 478            drawOverlay(
 479                    bitmap,
 480                    paintOverlayBlack(bitmap)
 481                            ? R.drawable.play_video_black
 482                            : R.drawable.play_video_white,
 483                    0.75f);
 484        } else {
 485            bitmap = cropCenterSquare(attachment.getUri(), size);
 486            if (bitmap != null && "image/gif".equals(mime)) {
 487                Bitmap withGifOverlay = bitmap.copy(Bitmap.Config.ARGB_8888, true);
 488                drawOverlay(
 489                        withGifOverlay,
 490                        paintOverlayBlack(withGifOverlay)
 491                                ? R.drawable.play_gif_black
 492                                : R.drawable.play_gif_white,
 493                        1.0f);
 494                bitmap.recycle();
 495                bitmap = withGifOverlay;
 496            }
 497        }
 498        if (key != null && bitmap != null) {
 499            cache.put(key, new BitmapDrawable(bitmap));
 500        }
 501        return bitmap;
 502    }
 503
 504    public void updateMediaScanner(File file) {
 505        updateMediaScanner(file, null);
 506    }
 507
 508    public void updateMediaScanner(File file, final Runnable callback) {
 509        MediaScannerConnection.scanFile(
 510                mXmppConnectionService,
 511                new String[] {file.getAbsolutePath()},
 512                null,
 513                new MediaScannerConnection.MediaScannerConnectionClient() {
 514                    @Override
 515                    public void onMediaScannerConnected() {}
 516
 517                    @Override
 518                    public void onScanCompleted(String path, Uri uri) {
 519                        if (callback != null && file.getAbsolutePath().equals(path)) {
 520                            callback.run();
 521                        } else {
 522                            Log.d(Config.LOGTAG, "media scanner scanned wrong file");
 523                            if (callback != null) {
 524                                callback.run();
 525                            }
 526                        }
 527                    }
 528                });
 529    }
 530
 531    public boolean deleteFile(Message message) {
 532        File file = getFile(message);
 533        if (file.delete()) {
 534            updateMediaScanner(file);
 535            return true;
 536        } else {
 537            return false;
 538        }
 539    }
 540
 541    public DownloadableFile getFile(Message message) {
 542        return getFile(message, true);
 543    }
 544
 545    public DownloadableFile getFileForPath(String path) {
 546        return getFileForPath(
 547                path,
 548                MimeUtils.guessMimeTypeFromExtension(MimeUtils.extractRelevantExtension(path)));
 549    }
 550
 551    private DownloadableFile getFileForPath(final String path, final String mime) {
 552        if (path.startsWith("/")) {
 553            return new DownloadableFile(path);
 554        } else {
 555            return getLegacyFileForFilename(path, mime);
 556        }
 557    }
 558
 559    public DownloadableFile getLegacyFileForFilename(final String filename, final String mime) {
 560        if (Strings.isNullOrEmpty(mime)) {
 561            return new DownloadableFile(getLegacyStorageLocation("Files"), filename);
 562        } else if (mime.startsWith("image/")) {
 563            return new DownloadableFile(getLegacyStorageLocation("Images"), filename);
 564        } else if (mime.startsWith("video/")) {
 565            return new DownloadableFile(getLegacyStorageLocation("Videos"), filename);
 566        } else {
 567            return new DownloadableFile(getLegacyStorageLocation("Files"), filename);
 568        }
 569    }
 570
 571    public boolean isInternalFile(final File file) {
 572        final File internalFile = getFileForPath(file.getName());
 573        return file.getAbsolutePath().equals(internalFile.getAbsolutePath());
 574    }
 575
 576    public DownloadableFile getFile(Message message, boolean decrypted) {
 577        final boolean encrypted =
 578                !decrypted
 579                        && (message.getEncryption() == Message.ENCRYPTION_PGP
 580                                || message.getEncryption() == Message.ENCRYPTION_DECRYPTED);
 581        String path = message.getRelativeFilePath();
 582        if (path == null) {
 583            path = message.getUuid();
 584        }
 585        final DownloadableFile file = getFileForPath(path, message.getMimeType());
 586        if (encrypted) {
 587            return new DownloadableFile(
 588                    mXmppConnectionService.getCacheDir(),
 589                    String.format("%s.%s", file.getName(), "pgp"));
 590        } else {
 591            return file;
 592        }
 593    }
 594
 595    public List<Attachment> convertToAttachments(List<DatabaseBackend.FilePath> relativeFilePaths) {
 596        final List<Attachment> attachments = new ArrayList<>();
 597        for (final DatabaseBackend.FilePath relativeFilePath : relativeFilePaths) {
 598            final String mime =
 599                    MimeUtils.guessMimeTypeFromExtension(
 600                            MimeUtils.extractRelevantExtension(relativeFilePath.path));
 601            final File file = getFileForPath(relativeFilePath.path, mime);
 602            attachments.add(Attachment.of(relativeFilePath.uuid, file, mime));
 603        }
 604        return attachments;
 605    }
 606
 607    private File getLegacyStorageLocation(final String type) {
 608        if (Config.ONLY_INTERNAL_STORAGE) {
 609            return new File(mXmppConnectionService.getFilesDir(), type);
 610        } else {
 611            final File appDirectory =
 612                    new File(
 613                            Environment.getExternalStorageDirectory(),
 614                            mXmppConnectionService.getString(R.string.app_name));
 615            final File appMediaDirectory = new File(appDirectory, "Media");
 616            final String locationName =
 617                    String.format(
 618                            "%s %s", mXmppConnectionService.getString(R.string.app_name), type);
 619            return new File(appMediaDirectory, locationName);
 620        }
 621    }
 622
 623    private Bitmap resize(final Bitmap originalBitmap, int size) throws IOException {
 624        int w = originalBitmap.getWidth();
 625        int h = originalBitmap.getHeight();
 626        if (w <= 0 || h <= 0) {
 627            throw new IOException("Decoded bitmap reported bounds smaller 0");
 628        } else if (Math.max(w, h) > size) {
 629            int scalledW;
 630            int scalledH;
 631            if (w <= h) {
 632                scalledW = Math.max((int) (w / ((double) h / size)), 1);
 633                scalledH = size;
 634            } else {
 635                scalledW = size;
 636                scalledH = Math.max((int) (h / ((double) w / size)), 1);
 637            }
 638            final Bitmap result =
 639                    Bitmap.createScaledBitmap(originalBitmap, scalledW, scalledH, true);
 640            if (!originalBitmap.isRecycled()) {
 641                originalBitmap.recycle();
 642            }
 643            return result;
 644        } else {
 645            return originalBitmap;
 646        }
 647    }
 648
 649    public boolean useImageAsIs(final Uri uri) {
 650        final String path = getOriginalPath(uri);
 651        if (path == null || isPathBlacklisted(path)) {
 652            return false;
 653        }
 654        final File file = new File(path);
 655        long size = file.length();
 656        if (size == 0
 657                || size
 658                        >= mXmppConnectionService
 659                                .getResources()
 660                                .getInteger(R.integer.auto_accept_filesize)) {
 661            return false;
 662        }
 663        BitmapFactory.Options options = new BitmapFactory.Options();
 664        options.inJustDecodeBounds = true;
 665        try {
 666            for (Cid cid : calculateCids(uri)) {
 667                if (mXmppConnectionService.getUrlForCid(cid) != null) return true;
 668            }
 669            final InputStream inputStream =
 670                    mXmppConnectionService.getContentResolver().openInputStream(uri);
 671            BitmapFactory.decodeStream(inputStream, null, options);
 672            close(inputStream);
 673            if (options.outMimeType == null || options.outHeight <= 0 || options.outWidth <= 0) {
 674                return false;
 675            }
 676            return (options.outWidth <= Config.IMAGE_SIZE
 677                    && options.outHeight <= Config.IMAGE_SIZE
 678                    && options.outMimeType.contains(Config.IMAGE_FORMAT.name().toLowerCase()));
 679        } catch (final IOException e) {
 680            Log.d(Config.LOGTAG, "unable to get image dimensions", e);
 681            return false;
 682        }
 683    }
 684
 685    public String getOriginalPath(Uri uri) {
 686        return FileUtils.getPath(mXmppConnectionService, uri);
 687    }
 688
 689    public void copyFileToDocumentFile(Context ctx, File file, DocumentFile df) throws FileCopyException {
 690        Log.d(
 691                Config.LOGTAG,
 692                "copy file (" + file + ") to " + df);
 693        try (final InputStream is = new FileInputStream(file);
 694                final OutputStream os =
 695                        mXmppConnectionService.getContentResolver().openOutputStream(df.getUri())) {
 696            if (is == null) {
 697                throw new FileCopyException(R.string.error_file_not_found);
 698            }
 699            try {
 700                ByteStreams.copy(is, os);
 701                os.flush();
 702            } catch (IOException e) {
 703                throw new FileWriterException(file);
 704            }
 705        } catch (final FileNotFoundException e) {
 706            throw new FileCopyException(R.string.error_file_not_found);
 707        } catch (final FileWriterException e) {
 708            throw new FileCopyException(R.string.error_unable_to_create_temporary_file);
 709        } catch (final SecurityException | IllegalStateException e) {
 710            throw new FileCopyException(R.string.error_security_exception);
 711        } catch (final IOException e) {
 712            throw new FileCopyException(R.string.error_io_exception);
 713        }
 714    }
 715
 716    private void copyFileToPrivateStorage(File file, Uri uri) throws FileCopyException {
 717        Log.d(
 718                Config.LOGTAG,
 719                "copy file (" + uri.toString() + ") to private storage " + file.getAbsolutePath());
 720        file.getParentFile().mkdirs();
 721        try {
 722            file.createNewFile();
 723        } catch (IOException e) {
 724            throw new FileCopyException(R.string.error_unable_to_create_temporary_file);
 725        }
 726        try (final OutputStream os = new FileOutputStream(file);
 727                final InputStream is =
 728                        mXmppConnectionService.getContentResolver().openInputStream(uri)) {
 729            if (is == null) {
 730                throw new FileCopyException(R.string.error_file_not_found);
 731            }
 732            try {
 733                ByteStreams.copy(is, os);
 734            } catch (IOException e) {
 735                throw new FileWriterException(file);
 736            }
 737            try {
 738                os.flush();
 739            } catch (IOException e) {
 740                throw new FileWriterException(file);
 741            }
 742        } catch (final FileNotFoundException e) {
 743            cleanup(file);
 744            throw new FileCopyException(R.string.error_file_not_found);
 745        } catch (final FileWriterException e) {
 746            cleanup(file);
 747            throw new FileCopyException(R.string.error_unable_to_create_temporary_file);
 748        } catch (final SecurityException | IllegalStateException e) {
 749            cleanup(file);
 750            throw new FileCopyException(R.string.error_security_exception);
 751        } catch (final IOException e) {
 752            cleanup(file);
 753            throw new FileCopyException(R.string.error_io_exception);
 754        }
 755    }
 756
 757    public void copyFileToPrivateStorage(Message message, Uri uri, String type)
 758            throws FileCopyException {
 759        String mime = MimeUtils.guessMimeTypeFromUriAndMime(mXmppConnectionService, uri, type);
 760        Log.d(Config.LOGTAG, "copy " + uri.toString() + " to private storage (mime=" + mime + ")");
 761        String extension = MimeUtils.guessExtensionFromMimeType(mime);
 762        if (extension == null) {
 763            Log.d(Config.LOGTAG, "extension from mime type was null");
 764            extension = getExtensionFromUri(uri);
 765        }
 766        if ("ogg".equals(extension) && type != null && type.startsWith("audio/")) {
 767            extension = "oga";
 768        }
 769
 770        try {
 771            setupRelativeFilePath(message, uri, extension);
 772            copyFileToPrivateStorage(mXmppConnectionService.getFileBackend().getFile(message), uri);
 773            final String name = getDisplayNameFromUri(uri);
 774            if (name != null) {
 775                message.getFileParams().setName(name);
 776            }
 777        } catch (final XmppConnectionService.BlockedMediaException e) {
 778            message.setRelativeFilePath(null);
 779            message.setDeleted(true);
 780        }
 781    }
 782
 783    private String getDisplayNameFromUri(final Uri uri) {
 784        final String[] projection = {OpenableColumns.DISPLAY_NAME};
 785        String filename = null;
 786        try (final Cursor cursor =
 787                mXmppConnectionService
 788                        .getContentResolver()
 789                        .query(uri, projection, null, null, null)) {
 790            if (cursor != null && cursor.moveToFirst()) {
 791                filename = cursor.getString(0);
 792            }
 793        } catch (final Exception e) {
 794            filename = null;
 795        }
 796        return filename;
 797    }
 798
 799    private String getExtensionFromUri(final Uri uri) {
 800        final String[] projection = {MediaStore.MediaColumns.DATA};
 801        String filename = null;
 802        try (final Cursor cursor =
 803                mXmppConnectionService
 804                        .getContentResolver()
 805                        .query(uri, projection, null, null, null)) {
 806            if (cursor != null && cursor.moveToFirst()) {
 807                filename = cursor.getString(0);
 808            }
 809        } catch (final Exception e) {
 810            filename = null;
 811        }
 812        if (filename == null) {
 813            final List<String> segments = uri.getPathSegments();
 814            if (segments.size() > 0) {
 815                filename = segments.get(segments.size() - 1);
 816            }
 817        }
 818        final int pos = filename == null ? -1 : filename.lastIndexOf('.');
 819        return pos > 0 ? filename.substring(pos + 1) : null;
 820    }
 821
 822    private void copyImageToPrivateStorage(File file, Uri image, int sampleSize)
 823            throws FileCopyException, ImageCompressionException {
 824        final File parent = file.getParentFile();
 825        if (parent != null && parent.mkdirs()) {
 826            Log.d(Config.LOGTAG, "created parent directory");
 827        }
 828        InputStream is = null;
 829        OutputStream os = null;
 830        try {
 831            if (!file.exists() && !file.createNewFile()) {
 832                throw new FileCopyException(R.string.error_unable_to_create_temporary_file);
 833            }
 834            is = mXmppConnectionService.getContentResolver().openInputStream(image);
 835            if (is == null) {
 836                throw new FileCopyException(R.string.error_not_an_image_file);
 837            }
 838            final Bitmap originalBitmap;
 839            final BitmapFactory.Options options = new BitmapFactory.Options();
 840            final int inSampleSize = (int) Math.pow(2, sampleSize);
 841            Log.d(Config.LOGTAG, "reading bitmap with sample size " + inSampleSize);
 842            options.inSampleSize = inSampleSize;
 843            originalBitmap = BitmapFactory.decodeStream(is, null, options);
 844            is.close();
 845            if (originalBitmap == null) {
 846                throw new ImageCompressionException("Source file was not an image");
 847            }
 848            if (!"image/jpeg".equals(options.outMimeType) && hasAlpha(originalBitmap)) {
 849                originalBitmap.recycle();
 850                throw new ImageCompressionException("Source file had alpha channel");
 851            }
 852            Bitmap scaledBitmap = resize(originalBitmap, Config.IMAGE_SIZE);
 853            final int rotation = getRotation(image);
 854            scaledBitmap = rotate(scaledBitmap, rotation);
 855            boolean targetSizeReached = false;
 856            int quality = Config.IMAGE_QUALITY;
 857            final int imageMaxSize =
 858                    mXmppConnectionService
 859                            .getResources()
 860                            .getInteger(R.integer.auto_accept_filesize);
 861            while (!targetSizeReached) {
 862                os = new FileOutputStream(file);
 863                Log.d(Config.LOGTAG, "compressing image with quality " + quality);
 864                boolean success = scaledBitmap.compress(Config.IMAGE_FORMAT, quality, os);
 865                if (!success) {
 866                    throw new FileCopyException(R.string.error_compressing_image);
 867                }
 868                os.flush();
 869                final long fileSize = file.length();
 870                Log.d(Config.LOGTAG, "achieved file size of " + fileSize);
 871                targetSizeReached = fileSize <= imageMaxSize || quality <= 50;
 872                quality -= 5;
 873            }
 874            scaledBitmap.recycle();
 875        } catch (final FileNotFoundException e) {
 876            cleanup(file);
 877            throw new FileCopyException(R.string.error_file_not_found);
 878        } catch (final IOException e) {
 879            cleanup(file);
 880            throw new FileCopyException(R.string.error_io_exception);
 881        } catch (SecurityException e) {
 882            cleanup(file);
 883            throw new FileCopyException(R.string.error_security_exception_during_image_copy);
 884        } catch (final OutOfMemoryError e) {
 885            ++sampleSize;
 886            if (sampleSize <= 3) {
 887                copyImageToPrivateStorage(file, image, sampleSize);
 888            } else {
 889                throw new FileCopyException(R.string.error_out_of_memory);
 890            }
 891        } finally {
 892            close(os);
 893            close(is);
 894        }
 895    }
 896
 897    private static void cleanup(final File file) {
 898        try {
 899            file.delete();
 900        } catch (Exception e) {
 901
 902        }
 903    }
 904
 905    public void copyImageToPrivateStorage(File file, Uri image)
 906            throws FileCopyException, ImageCompressionException {
 907        Log.d(
 908                Config.LOGTAG,
 909                "copy image ("
 910                        + image.toString()
 911                        + ") to private storage "
 912                        + file.getAbsolutePath());
 913        copyImageToPrivateStorage(file, image, 0);
 914    }
 915
 916    public void copyImageToPrivateStorage(Message message, Uri image)
 917            throws FileCopyException, ImageCompressionException {
 918        final String filename;
 919        switch (Config.IMAGE_FORMAT) {
 920            case JPEG:
 921                filename = String.format("%s.%s", message.getUuid(), "jpg");
 922                break;
 923            case PNG:
 924                filename = String.format("%s.%s", message.getUuid(), "png");
 925                break;
 926            case WEBP:
 927                filename = String.format("%s.%s", message.getUuid(), "webp");
 928                break;
 929            default:
 930                throw new IllegalStateException("Unknown image format");
 931        }
 932        setupRelativeFilePath(message, filename);
 933        final File tmp = getFile(message);
 934        copyImageToPrivateStorage(tmp, image);
 935        final String extension = MimeUtils.extractRelevantExtension(filename);
 936        try {
 937            setupRelativeFilePath(message, new FileInputStream(tmp), extension);
 938        } catch (final FileNotFoundException e) {
 939            throw new FileCopyException(R.string.error_file_not_found);
 940        } catch (final IOException e) {
 941            throw new FileCopyException(R.string.error_io_exception);
 942        } catch (final XmppConnectionService.BlockedMediaException e) {
 943            tmp.delete();
 944            message.setRelativeFilePath(null);
 945            message.setDeleted(true);
 946            return;
 947        }
 948        tmp.renameTo(getFile(message));
 949        updateFileParams(message, null, false);
 950    }
 951
 952    public void setupRelativeFilePath(final Message message, final Uri uri, final String extension) throws FileCopyException, XmppConnectionService.BlockedMediaException {
 953        try {
 954            setupRelativeFilePath(message, mXmppConnectionService.getContentResolver().openInputStream(uri), extension);
 955        } catch (final FileNotFoundException e) {
 956            throw new FileCopyException(R.string.error_file_not_found);
 957        } catch (final IOException e) {
 958            throw new FileCopyException(R.string.error_io_exception);
 959        }
 960    }
 961
 962    public Cid[] calculateCids(final Uri uri) throws IOException {
 963        return calculateCids(mXmppConnectionService.getContentResolver().openInputStream(uri));
 964    }
 965
 966    public Cid[] calculateCids(final InputStream is) throws IOException {
 967        try {
 968            return CryptoHelper.cid(is, new String[]{"SHA-256", "SHA-1", "SHA-512"});
 969        } catch (final NoSuchAlgorithmException e) {
 970            throw new AssertionError(e);
 971        }
 972    }
 973
 974    public void setupRelativeFilePath(final Message message, final InputStream is, final String extension) throws IOException, XmppConnectionService.BlockedMediaException {
 975        message.setRelativeFilePath(getStorageLocation(is, extension).getAbsolutePath());
 976    }
 977
 978    public void setupRelativeFilePath(final Message message, final String filename) {
 979        final String extension = MimeUtils.extractRelevantExtension(filename);
 980        final String mime = MimeUtils.guessMimeTypeFromExtension(extension);
 981        setupRelativeFilePath(message, filename, mime);
 982    }
 983
 984    public File getStorageLocation(final InputStream is, final String extension) throws IOException, XmppConnectionService.BlockedMediaException {
 985        final String mime = MimeUtils.guessMimeTypeFromExtension(extension);
 986        Cid[] cids = calculateCids(is);
 987        String base = cids[0].toString();
 988
 989        File file = null;
 990        while (file == null || (file.exists() && !file.canRead())) {
 991            file = getStorageLocation(String.format("%s.%s", base, extension), mime);
 992            base += "_";
 993        }
 994        for (int i = 0; i < cids.length; i++) {
 995            mXmppConnectionService.saveCid(cids[i], file);
 996        }
 997        return file;
 998    }
 999
1000    public File getStorageLocation(final String filename, final String mime) {
1001        final File parentDirectory;
1002        if (Strings.isNullOrEmpty(mime)) {
1003            parentDirectory =
1004                    Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS);
1005        } else if (mime.startsWith("image/")) {
1006            parentDirectory =
1007                    Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES);
1008        } else if (mime.startsWith("video/")) {
1009            parentDirectory =
1010                    Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MOVIES);
1011        } else if (MediaAdapter.DOCUMENT_MIMES.contains(mime)) {
1012            parentDirectory =
1013                    Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOCUMENTS);
1014        } else {
1015            parentDirectory =
1016                    Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS);
1017        }
1018        final File appDirectory =
1019                new File(parentDirectory, mXmppConnectionService.getString(R.string.app_name));
1020        return new File(appDirectory, filename);
1021    }
1022
1023    public static boolean inConversationsDirectory(final Context context, String path) {
1024        final File fileDirectory = new File(path).getParentFile();
1025        for (final String type : STORAGE_TYPES) {
1026            final File typeDirectory =
1027                    new File(
1028                            Environment.getExternalStoragePublicDirectory(type),
1029                            context.getString(R.string.app_name));
1030            if (typeDirectory.equals(fileDirectory)) {
1031                return true;
1032            }
1033        }
1034        return false;
1035    }
1036
1037    public void setupRelativeFilePath(
1038            final Message message, final String filename, final String mime) {
1039        final File file = getStorageLocation(filename, mime);
1040        message.setRelativeFilePath(file.getAbsolutePath());
1041    }
1042
1043    public boolean unusualBounds(final Uri image) {
1044        try {
1045            final BitmapFactory.Options options = new BitmapFactory.Options();
1046            options.inJustDecodeBounds = true;
1047            final InputStream inputStream =
1048                    mXmppConnectionService.getContentResolver().openInputStream(image);
1049            BitmapFactory.decodeStream(inputStream, null, options);
1050            close(inputStream);
1051            float ratio = (float) options.outHeight / options.outWidth;
1052            return ratio > (21.0f / 9.0f) || ratio < (9.0f / 21.0f);
1053        } catch (final Exception e) {
1054            Log.w(Config.LOGTAG, "unable to detect image bounds", e);
1055            return false;
1056        }
1057    }
1058
1059    private int getRotation(final File file) {
1060        try (final InputStream inputStream = new FileInputStream(file)) {
1061            return getRotation(inputStream);
1062        } catch (Exception e) {
1063            return 0;
1064        }
1065    }
1066
1067    private int getRotation(final Uri image) {
1068        try (final InputStream is =
1069                mXmppConnectionService.getContentResolver().openInputStream(image)) {
1070            return is == null ? 0 : getRotation(is);
1071        } catch (final Exception e) {
1072            return 0;
1073        }
1074    }
1075
1076    private static int getRotation(final InputStream inputStream) throws IOException {
1077        final ExifInterface exif = new ExifInterface(inputStream);
1078        final int orientation =
1079                exif.getAttributeInt(
1080                        ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_UNDEFINED);
1081        switch (orientation) {
1082            case ExifInterface.ORIENTATION_ROTATE_180:
1083                return 180;
1084            case ExifInterface.ORIENTATION_ROTATE_90:
1085                return 90;
1086            case ExifInterface.ORIENTATION_ROTATE_270:
1087                return 270;
1088            default:
1089                return 0;
1090        }
1091    }
1092
1093    public BitmapDrawable getFallbackThumbnail(final Message message, int size, boolean cacheOnly) {
1094        List<Element> thumbs = message.getFileParams() != null ? message.getFileParams().getThumbnails() : null;
1095        if (thumbs != null && !thumbs.isEmpty()) {
1096            for (Element thumb : thumbs) {
1097                Uri uri = Uri.parse(thumb.getAttribute("uri"));
1098                if (uri.getScheme().equals("data")) {
1099                    String[] parts = uri.getSchemeSpecificPart().split(",", 2);
1100
1101                    final LruCache<String, Drawable> cache = mXmppConnectionService.getDrawableCache();
1102                    BitmapDrawable cached = (BitmapDrawable) cache.get(parts[1]);
1103                    if (cached != null || cacheOnly) return cached;
1104
1105                    byte[] data;
1106                    if (Arrays.asList(parts[0].split(";")).contains("base64")) {
1107                        String[] parts2 = parts[0].split(";", 2);
1108                        parts[0] = parts2[0];
1109                        data = Base64.decode(parts[1], 0);
1110                    } else {
1111                        try {
1112                            data = parts[1].getBytes("UTF-8");
1113                        } catch (final IOException e) {
1114                            data = new byte[0];
1115                        }
1116                    }
1117
1118                    if (parts[0].equals("image/blurhash")) {
1119                        int width = message.getFileParams().width;
1120                        if (width < 1 && thumb.getAttribute("width") != null) width = Integer.parseInt(thumb.getAttribute("width"));
1121                        if (width < 1) width = 1920;
1122
1123                        int height = message.getFileParams().height;
1124                        if (height < 1 && thumb.getAttribute("height") != null) height = Integer.parseInt(thumb.getAttribute("height"));
1125                        if (height < 1) height = 1080;
1126                        Rect r = rectForSize(width, height, size);
1127
1128                        Bitmap blurhash = BlurHashDecoder.INSTANCE.decode(parts[1], r.width(), r.height(), 1.0f, false);
1129                        if (blurhash != null) {
1130                            cached = new BitmapDrawable(blurhash);
1131                            if (parts[1] != null && cached != null) cache.put(parts[1], cached);
1132                            return cached;
1133                        }
1134                    } else if (parts[0].equals("image/thumbhash")) {
1135                        ThumbHash.Image image;
1136                        try {
1137                            image = ThumbHash.thumbHashToRGBA(data);
1138                        } catch (final Exception e) {
1139                            continue;
1140                        }
1141                        int[] pixels = new int[image.width * image.height];
1142                        for (int i = 0; i < pixels.length; i++) {
1143                            pixels[i] = Color.argb(image.rgba[(i*4)+3] & 0xff, image.rgba[i*4] & 0xff, image.rgba[(i*4)+1] & 0xff, image.rgba[(i*4)+2] & 0xff);
1144                        }
1145                        cached = new BitmapDrawable(Bitmap.createBitmap(pixels, image.width, image.height, Bitmap.Config.ARGB_8888));
1146                        if (parts[1] != null && cached != null) cache.put(parts[1], cached);
1147                        return cached;
1148                    }
1149                }
1150            }
1151         }
1152
1153        return null;
1154    }
1155
1156    public Drawable getThumbnail(Message message, Resources res, int size, boolean cacheOnly) throws IOException {
1157        final LruCache<String, Drawable> cache = mXmppConnectionService.getDrawableCache();
1158        DownloadableFile file = getFile(message);
1159        Drawable thumbnail = cache.get(file.getAbsolutePath());
1160        if (thumbnail != null) return thumbnail;
1161
1162        if ((thumbnail == null) && (!cacheOnly)) {
1163            synchronized (THUMBNAIL_LOCK) {
1164                List<Element> thumbs = message.getFileParams() != null ? message.getFileParams().getThumbnails() : null;
1165                if (thumbs != null && !thumbs.isEmpty()) {
1166                    for (Element thumb : thumbs) {
1167                        Uri uri = Uri.parse(thumb.getAttribute("uri"));
1168                        if (uri.getScheme().equals("data")) {
1169                            if (android.os.Build.VERSION.SDK_INT < 28) continue;
1170                            String[] parts = uri.getSchemeSpecificPart().split(",", 2);
1171
1172                            byte[] data;
1173                            if (Arrays.asList(parts[0].split(";")).contains("base64")) {
1174                                String[] parts2 = parts[0].split(";", 2);
1175                                parts[0] = parts2[0];
1176                                data = Base64.decode(parts[1], 0);
1177                            } else {
1178                                data = parts[1].getBytes("UTF-8");
1179                            }
1180
1181                            if (parts[0].equals("image/blurhash")) continue; // blurhash only for fallback
1182                            if (parts[0].equals("image/thumbhash")) continue; // thumbhash only for fallback
1183
1184                            ImageDecoder.Source source = ImageDecoder.createSource(ByteBuffer.wrap(data));
1185                            thumbnail = ImageDecoder.decodeDrawable(source, (decoder, info, src) -> {
1186                                int w = info.getSize().getWidth();
1187                                int h = info.getSize().getHeight();
1188                                Rect r = rectForSize(w, h, size);
1189                                decoder.setTargetSize(r.width(), r.height());
1190                            });
1191
1192                            if (thumbnail != null && file.getAbsolutePath() != null) {
1193                                cache.put(file.getAbsolutePath(), thumbnail);
1194                                return thumbnail;
1195                            }
1196                        } else if (uri.getScheme().equals("cid")) {
1197                            Cid cid = BobTransfer.cid(uri);
1198                            if (cid == null) continue;
1199                            DownloadableFile f = mXmppConnectionService.getFileForCid(cid);
1200                            if (f != null && f.canRead()) {
1201                                return getThumbnail(f, res, size, cacheOnly);
1202                            }
1203                        }
1204                    }
1205                }
1206            }
1207        }
1208
1209        return getThumbnail(file, res, size, cacheOnly);
1210    }
1211
1212    public Drawable getThumbnail(DownloadableFile file, Resources res, int size, boolean cacheOnly) throws IOException {
1213        return getThumbnail(file, res, size, cacheOnly, file.getAbsolutePath());
1214    }
1215
1216    public Drawable getThumbnail(DownloadableFile file, Resources res, int size, boolean cacheOnly, String cacheKey) throws IOException {
1217        final LruCache<String, Drawable> cache = mXmppConnectionService.getDrawableCache();
1218        Drawable thumbnail = cache.get(cacheKey);
1219        if ((thumbnail == null) && (!cacheOnly) && file.exists()) {
1220            synchronized (THUMBNAIL_LOCK) {
1221                thumbnail = cache.get(cacheKey);
1222                if (thumbnail != null) {
1223                    return thumbnail;
1224                }
1225                final String mime = file.getMimeType();
1226                if ("image/svg+xml".equals(mime)) {
1227                    thumbnail = getSVG(file, size);
1228                } else if ("application/pdf".equals(mime)) {
1229                    thumbnail = new BitmapDrawable(res, getPdfDocumentPreview(file, size));
1230                } else if (mime.startsWith("video/")) {
1231                    thumbnail = new BitmapDrawable(res, getVideoPreview(file, size));
1232                } else {
1233                    thumbnail = getImagePreview(file, res, size, mime);
1234                    if (thumbnail == null) {
1235                        throw new FileNotFoundException();
1236                    }
1237                }
1238                if (cacheKey != null && thumbnail != null) cache.put(cacheKey, thumbnail);
1239            }
1240        }
1241        return thumbnail;
1242    }
1243
1244    public Bitmap getThumbnailBitmap(Message message, Resources res, int size) throws IOException {
1245          final Drawable drawable = getThumbnail(message, res, size, false);
1246          if (drawable == null) return null;
1247          return drawDrawable(drawable);
1248    }
1249
1250    public Bitmap getThumbnailBitmap(DownloadableFile file, Resources res, int size, String cacheKey) throws IOException {
1251          final Drawable drawable = getThumbnail(file, res, size, false, cacheKey);
1252          if (drawable == null) return null;
1253          return drawDrawable(drawable);
1254    }
1255
1256    public static Rect rectForSize(int w, int h, int size) {
1257        int scalledW;
1258        int scalledH;
1259        if (w <= h) {
1260            scalledW = Math.max((int) (w / ((double) h / size)), 1);
1261            scalledH = size;
1262        } else {
1263            scalledW = size;
1264            scalledH = Math.max((int) (h / ((double) w / size)), 1);
1265        }
1266
1267        if (scalledW > w || scalledH > h) return new Rect(0, 0, w, h);
1268
1269        return new Rect(0, 0, scalledW, scalledH);
1270    }
1271
1272    private Drawable getImagePreview(File file, Resources res, int size, final String mime) throws IOException {
1273        if (android.os.Build.VERSION.SDK_INT >= 28) {
1274            ImageDecoder.Source source = ImageDecoder.createSource(file);
1275            return ImageDecoder.decodeDrawable(source, (decoder, info, src) -> {
1276                int w = info.getSize().getWidth();
1277                int h = info.getSize().getHeight();
1278                Rect r = rectForSize(w, h, size);
1279                decoder.setTargetSize(r.width(), r.height());
1280            });
1281        } else {
1282            BitmapFactory.Options options = new BitmapFactory.Options();
1283            options.inSampleSize = calcSampleSize(file, size);
1284            Bitmap bitmap = null;
1285            try {
1286                bitmap = BitmapFactory.decodeFile(file.getAbsolutePath(), options);
1287            } catch (OutOfMemoryError e) {
1288                options.inSampleSize *= 2;
1289                bitmap = BitmapFactory.decodeFile(file.getAbsolutePath(), options);
1290            }
1291            if (bitmap == null) return null;
1292
1293            bitmap = resize(bitmap, size);
1294            bitmap = rotate(bitmap, getRotation(file));
1295            if (mime.equals("image/gif")) {
1296                Bitmap withGifOverlay = bitmap.copy(Bitmap.Config.ARGB_8888, true);
1297                drawOverlay(withGifOverlay, paintOverlayBlack(withGifOverlay) ? R.drawable.play_gif_black : R.drawable.play_gif_white, 1.0f);
1298                bitmap.recycle();
1299                bitmap = withGifOverlay;
1300            }
1301            return new BitmapDrawable(res, bitmap);
1302        }
1303    }
1304
1305    public static Bitmap drawDrawable(Drawable drawable) {
1306        if (drawable == null) return null;
1307
1308        Bitmap bitmap = null;
1309
1310        if (drawable instanceof BitmapDrawable) {
1311            bitmap = ((BitmapDrawable) drawable).getBitmap();
1312            if (bitmap != null) return bitmap;
1313        }
1314
1315        Rect bounds = drawable.getBounds();
1316        int width = drawable.getIntrinsicWidth();
1317        if (width < 1) width = bounds == null || bounds.right < 1 ? 256 : bounds.right;
1318        int height = drawable.getIntrinsicHeight();
1319        if (height < 1) height = bounds == null || bounds.bottom < 1 ? 256 : bounds.bottom;
1320
1321        bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
1322        Canvas canvas = new Canvas(bitmap);
1323        drawable.setBounds(0, 0, canvas.getWidth(), canvas.getHeight());
1324        drawable.draw(canvas);
1325        return bitmap;
1326    }
1327
1328    private void drawOverlay(Bitmap bitmap, int resource, float factor) {
1329        Bitmap overlay =
1330                BitmapFactory.decodeResource(mXmppConnectionService.getResources(), resource);
1331        Canvas canvas = new Canvas(bitmap);
1332        float targetSize = Math.min(canvas.getWidth(), canvas.getHeight()) * factor;
1333        Log.d(
1334                Config.LOGTAG,
1335                "target size overlay: "
1336                        + targetSize
1337                        + " overlay bitmap size was "
1338                        + overlay.getHeight());
1339        float left = (canvas.getWidth() - targetSize) / 2.0f;
1340        float top = (canvas.getHeight() - targetSize) / 2.0f;
1341        RectF dst = new RectF(left, top, left + targetSize - 1, top + targetSize - 1);
1342        canvas.drawBitmap(overlay, null, dst, createAntiAliasingPaint());
1343    }
1344
1345    /** https://stackoverflow.com/a/3943023/210897 */
1346    private boolean paintOverlayBlack(final Bitmap bitmap) {
1347        final int h = bitmap.getHeight();
1348        final int w = bitmap.getWidth();
1349        int record = 0;
1350        for (int y = Math.round(h * IGNORE_PADDING); y < h - Math.round(h * IGNORE_PADDING); ++y) {
1351            for (int x = Math.round(w * IGNORE_PADDING);
1352                    x < w - Math.round(w * IGNORE_PADDING);
1353                    ++x) {
1354                int pixel = bitmap.getPixel(x, y);
1355                if ((Color.red(pixel) * 0.299
1356                                + Color.green(pixel) * 0.587
1357                                + Color.blue(pixel) * 0.114)
1358                        > 186) {
1359                    --record;
1360                } else {
1361                    ++record;
1362                }
1363            }
1364        }
1365        return record < 0;
1366    }
1367
1368    private boolean paintOverlayBlackPdf(final Bitmap bitmap) {
1369        final int h = bitmap.getHeight();
1370        final int w = bitmap.getWidth();
1371        int white = 0;
1372        for (int y = 0; y < h; ++y) {
1373            for (int x = 0; x < w; ++x) {
1374                int pixel = bitmap.getPixel(x, y);
1375                if ((Color.red(pixel) * 0.299
1376                                + Color.green(pixel) * 0.587
1377                                + Color.blue(pixel) * 0.114)
1378                        > 186) {
1379                    white++;
1380                }
1381            }
1382        }
1383        return white > (h * w * 0.4f);
1384    }
1385
1386    private Bitmap cropCenterSquareVideo(Uri uri, int size) {
1387        MediaMetadataRetriever metadataRetriever = new MediaMetadataRetriever();
1388        Bitmap frame;
1389        try {
1390            metadataRetriever.setDataSource(mXmppConnectionService, uri);
1391            frame = metadataRetriever.getFrameAtTime(0);
1392            metadataRetriever.release();
1393            return cropCenterSquare(frame, size);
1394        } catch (Exception e) {
1395            frame = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888);
1396            frame.eraseColor(0xff000000);
1397            return frame;
1398        }
1399    }
1400
1401    private Bitmap getVideoPreview(final File file, final int size) {
1402        final MediaMetadataRetriever metadataRetriever = new MediaMetadataRetriever();
1403        Bitmap frame;
1404        try {
1405            metadataRetriever.setDataSource(file.getAbsolutePath());
1406            frame = metadataRetriever.getFrameAtTime(0);
1407            metadataRetriever.release();
1408            frame = resize(frame, size);
1409        } catch (IOException | RuntimeException e) {
1410            frame = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888);
1411            frame.eraseColor(0xff000000);
1412        }
1413        drawOverlay(
1414                frame,
1415                paintOverlayBlack(frame)
1416                        ? R.drawable.play_video_black
1417                        : R.drawable.play_video_white,
1418                0.75f);
1419        return frame;
1420    }
1421
1422    @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
1423    private Bitmap getPdfDocumentPreview(final File file, final int size) {
1424        try {
1425            final ParcelFileDescriptor fileDescriptor =
1426                    ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY);
1427            final Bitmap rendered = renderPdfDocument(fileDescriptor, size, true);
1428            drawOverlay(
1429                    rendered,
1430                    paintOverlayBlackPdf(rendered)
1431                            ? R.drawable.open_pdf_black
1432                            : R.drawable.open_pdf_white,
1433                    0.75f);
1434            return rendered;
1435        } catch (final IOException | SecurityException e) {
1436            Log.d(Config.LOGTAG, "unable to render PDF document preview", e);
1437            final Bitmap placeholder = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888);
1438            placeholder.eraseColor(0xff000000);
1439            return placeholder;
1440        }
1441    }
1442
1443    @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
1444    private Bitmap cropCenterSquarePdf(final Uri uri, final int size) {
1445        try {
1446            ParcelFileDescriptor fileDescriptor =
1447                    mXmppConnectionService.getContentResolver().openFileDescriptor(uri, "r");
1448            final Bitmap bitmap = renderPdfDocument(fileDescriptor, size, false);
1449            return cropCenterSquare(bitmap, size);
1450        } catch (Exception e) {
1451            final Bitmap placeholder = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888);
1452            placeholder.eraseColor(0xff000000);
1453            return placeholder;
1454        }
1455    }
1456
1457    @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
1458    private Bitmap renderPdfDocument(
1459            ParcelFileDescriptor fileDescriptor, int targetSize, boolean fit) throws IOException {
1460        final PdfRenderer pdfRenderer = new PdfRenderer(fileDescriptor);
1461        final PdfRenderer.Page page = pdfRenderer.openPage(0);
1462        final Dimensions dimensions =
1463                scalePdfDimensions(
1464                        new Dimensions(page.getHeight(), page.getWidth()), targetSize, fit);
1465        final Bitmap rendered =
1466                Bitmap.createBitmap(dimensions.width, dimensions.height, Bitmap.Config.ARGB_8888);
1467        rendered.eraseColor(0xffffffff);
1468        page.render(rendered, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY);
1469        page.close();
1470        pdfRenderer.close();
1471        fileDescriptor.close();
1472        return rendered;
1473    }
1474
1475    public Uri getTakePhotoUri() {
1476        final String filename =
1477                String.format("IMG_%s.%s", IMAGE_DATE_FORMAT.format(new Date()), "jpg");
1478        final File directory;
1479        if (Config.ONLY_INTERNAL_STORAGE) {
1480            directory = new File(mXmppConnectionService.getCacheDir(), "Camera");
1481        } else {
1482            directory =
1483                    new File(
1484                            Environment.getExternalStoragePublicDirectory(
1485                                    Environment.DIRECTORY_DCIM),
1486                            "Camera");
1487        }
1488        final File file = new File(directory, filename);
1489        file.getParentFile().mkdirs();
1490        return getUriForFile(mXmppConnectionService, file);
1491    }
1492
1493    public Avatar getPepAvatar(Uri image, int size, Bitmap.CompressFormat format) {
1494
1495        final Pair<Avatar,Boolean> uncompressAvatar = getUncompressedAvatar(image);
1496        if (uncompressAvatar != null && uncompressAvatar.first != null &&
1497                (uncompressAvatar.first.image.length() <= Config.AVATAR_CHAR_LIMIT || uncompressAvatar.second)) {
1498            return uncompressAvatar.first;
1499        }
1500        if (uncompressAvatar != null && uncompressAvatar.first != null) {
1501            Log.d(
1502                    Config.LOGTAG,
1503                    "uncompressed avatar exceeded char limit by "
1504                            + (uncompressAvatar.first.image.length() - Config.AVATAR_CHAR_LIMIT));
1505        }
1506
1507        Bitmap bm = cropCenterSquare(image, size);
1508        if (bm == null) {
1509            return null;
1510        }
1511        if (hasAlpha(bm)) {
1512            Log.d(Config.LOGTAG, "alpha in avatar detected; uploading as PNG");
1513            bm.recycle();
1514            bm = cropCenterSquare(image, 96);
1515            return getPepAvatar(bm, Bitmap.CompressFormat.PNG, 100);
1516        }
1517        return getPepAvatar(bm, format, 100);
1518    }
1519
1520    private Pair<Avatar,Boolean> getUncompressedAvatar(Uri uri) {
1521        try {
1522            if (android.os.Build.VERSION.SDK_INT >= 28) {
1523                ImageDecoder.Source source = ImageDecoder.createSource(mXmppConnectionService.getContentResolver(), uri);
1524                int[] size = new int[] { 0, 0 };
1525                boolean[] animated = new boolean[] { false };
1526                String[] mimeType = new String[] { null };
1527                Drawable drawable = ImageDecoder.decodeDrawable(source, (decoder, info, src) -> {
1528                    mimeType[0] = info.getMimeType();
1529                    animated[0] = info.isAnimated();
1530                    size[0] = info.getSize().getWidth();
1531                    size[1] = info.getSize().getHeight();
1532                });
1533
1534                if (animated[0]) {
1535                    Avatar avatar = getPepAvatar(uri, size[0], size[1], mimeType[0]);
1536                    if (avatar != null) return new Pair(avatar, true);
1537                }
1538
1539                return new Pair(getPepAvatar(drawDrawable(drawable), Bitmap.CompressFormat.PNG, 100), false);
1540            } else {
1541                Bitmap bitmap =
1542                    BitmapFactory.decodeStream(
1543                            mXmppConnectionService.getContentResolver().openInputStream(uri));
1544                return new Pair(getPepAvatar(bitmap, Bitmap.CompressFormat.PNG, 100), false);
1545            }
1546        } catch (Exception e) {
1547            try {
1548                final SVG svg = SVG.getFromInputStream(mXmppConnectionService.getContentResolver().openInputStream(uri));
1549                return new Pair(getPepAvatar(uri, (int) svg.getDocumentWidth(), (int) svg.getDocumentHeight(), "image/svg+xml"), true);
1550            } catch (Exception e2) {
1551                return null;
1552            }
1553        }
1554    }
1555
1556    private Avatar getPepAvatar(Uri uri, int width, int height, final String mimeType) throws IOException, NoSuchAlgorithmException {
1557        AssetFileDescriptor fd = mXmppConnectionService.getContentResolver().openAssetFileDescriptor(uri, "r");
1558        if (fd.getLength() > 100000) return null; // Too big to use raw file
1559
1560        ByteArrayOutputStream mByteArrayOutputStream = new ByteArrayOutputStream();
1561        Base64OutputStream mBase64OutputStream =
1562                new Base64OutputStream(mByteArrayOutputStream, Base64.DEFAULT);
1563        MessageDigest digest = MessageDigest.getInstance("SHA-1");
1564        DigestOutputStream mDigestOutputStream =
1565                new DigestOutputStream(mBase64OutputStream, digest);
1566
1567        ByteStreams.copy(fd.createInputStream(), mDigestOutputStream);
1568        mDigestOutputStream.flush();
1569        mDigestOutputStream.close();
1570
1571        final Avatar avatar = new Avatar();
1572        avatar.sha1sum = CryptoHelper.bytesToHex(digest.digest());
1573        avatar.image = new String(mByteArrayOutputStream.toByteArray());
1574        avatar.type = mimeType;
1575        avatar.width = width;
1576        avatar.height = height;
1577        return avatar;
1578    }
1579
1580    private Avatar getPepAvatar(Bitmap bitmap, Bitmap.CompressFormat format, int quality) {
1581        try {
1582            ByteArrayOutputStream mByteArrayOutputStream = new ByteArrayOutputStream();
1583            Base64OutputStream mBase64OutputStream =
1584                    new Base64OutputStream(mByteArrayOutputStream, Base64.DEFAULT);
1585            MessageDigest digest = MessageDigest.getInstance("SHA-1");
1586            DigestOutputStream mDigestOutputStream =
1587                    new DigestOutputStream(mBase64OutputStream, digest);
1588            if (!bitmap.compress(format, quality, mDigestOutputStream)) {
1589                return null;
1590            }
1591            mDigestOutputStream.flush();
1592            mDigestOutputStream.close();
1593            long chars = mByteArrayOutputStream.size();
1594            if (format != Bitmap.CompressFormat.PNG
1595                    && quality >= 50
1596                    && chars >= Config.AVATAR_CHAR_LIMIT) {
1597                int q = quality - 2;
1598                Log.d(
1599                        Config.LOGTAG,
1600                        "avatar char length was " + chars + " reducing quality to " + q);
1601                return getPepAvatar(bitmap, format, q);
1602            }
1603            Log.d(Config.LOGTAG, "settled on char length " + chars + " with quality=" + quality);
1604            final Avatar avatar = new Avatar();
1605            avatar.sha1sum = CryptoHelper.bytesToHex(digest.digest());
1606            avatar.image = new String(mByteArrayOutputStream.toByteArray());
1607            if (format.equals(Bitmap.CompressFormat.WEBP)) {
1608                avatar.type = "image/webp";
1609            } else if (format.equals(Bitmap.CompressFormat.JPEG)) {
1610                avatar.type = "image/jpeg";
1611            } else if (format.equals(Bitmap.CompressFormat.PNG)) {
1612                avatar.type = "image/png";
1613            }
1614            avatar.width = bitmap.getWidth();
1615            avatar.height = bitmap.getHeight();
1616            return avatar;
1617        } catch (OutOfMemoryError e) {
1618            Log.d(Config.LOGTAG, "unable to convert avatar to base64 due to low memory");
1619            return null;
1620        } catch (Exception e) {
1621            return null;
1622        }
1623    }
1624
1625    public Avatar getStoredPepAvatar(String hash) {
1626        if (hash == null) {
1627            return null;
1628        }
1629        Avatar avatar = new Avatar();
1630        final File file = getAvatarFile(hash);
1631        FileInputStream is = null;
1632        try {
1633            avatar.size = file.length();
1634            BitmapFactory.Options options = new BitmapFactory.Options();
1635            options.inJustDecodeBounds = true;
1636            BitmapFactory.decodeFile(file.getAbsolutePath(), options);
1637            is = new FileInputStream(file);
1638            ByteArrayOutputStream mByteArrayOutputStream = new ByteArrayOutputStream();
1639            Base64OutputStream mBase64OutputStream =
1640                    new Base64OutputStream(mByteArrayOutputStream, Base64.DEFAULT);
1641            MessageDigest digest = MessageDigest.getInstance("SHA-1");
1642            DigestOutputStream os = new DigestOutputStream(mBase64OutputStream, digest);
1643            byte[] buffer = new byte[4096];
1644            int length;
1645            while ((length = is.read(buffer)) > 0) {
1646                os.write(buffer, 0, length);
1647            }
1648            os.flush();
1649            os.close();
1650            avatar.sha1sum = CryptoHelper.bytesToHex(digest.digest());
1651            avatar.image = new String(mByteArrayOutputStream.toByteArray());
1652            avatar.height = options.outHeight;
1653            avatar.width = options.outWidth;
1654            avatar.type = options.outMimeType;
1655            return avatar;
1656        } catch (NoSuchAlgorithmException | IOException e) {
1657            return null;
1658        } finally {
1659            close(is);
1660        }
1661    }
1662
1663    public boolean isAvatarCached(Avatar avatar) {
1664        final File file = getAvatarFile(avatar.getFilename());
1665        return file.exists();
1666    }
1667
1668    public boolean save(final Avatar avatar) {
1669        File file;
1670        if (isAvatarCached(avatar)) {
1671            file = getAvatarFile(avatar.getFilename());
1672            avatar.size = file.length();
1673        } else {
1674            file =
1675                    new File(
1676                            mXmppConnectionService.getCacheDir().getAbsolutePath()
1677                                    + "/"
1678                                    + UUID.randomUUID().toString());
1679            if (file.getParentFile().mkdirs()) {
1680                Log.d(Config.LOGTAG, "created cache directory");
1681            }
1682            OutputStream os = null;
1683            try {
1684                if (!file.createNewFile()) {
1685                    Log.d(
1686                            Config.LOGTAG,
1687                            "unable to create temporary file " + file.getAbsolutePath());
1688                }
1689                os = new FileOutputStream(file);
1690                MessageDigest digest = MessageDigest.getInstance("SHA-1");
1691                digest.reset();
1692                DigestOutputStream mDigestOutputStream = new DigestOutputStream(os, digest);
1693                final byte[] bytes = avatar.getImageAsBytes();
1694                mDigestOutputStream.write(bytes);
1695                mDigestOutputStream.flush();
1696                mDigestOutputStream.close();
1697                String sha1sum = CryptoHelper.bytesToHex(digest.digest());
1698                if (sha1sum.equals(avatar.sha1sum)) {
1699                    final File outputFile = getAvatarFile(avatar.getFilename());
1700                    if (outputFile.getParentFile().mkdirs()) {
1701                        Log.d(Config.LOGTAG, "created avatar directory");
1702                    }
1703                    final File avatarFile = getAvatarFile(avatar.getFilename());
1704                    if (!file.renameTo(avatarFile)) {
1705                        Log.d(
1706                                Config.LOGTAG,
1707                                "unable to rename " + file.getAbsolutePath() + " to " + outputFile);
1708                        return false;
1709                    }
1710                } else {
1711                    Log.d(Config.LOGTAG, "sha1sum mismatch for " + avatar.owner);
1712                    if (!file.delete()) {
1713                        Log.d(Config.LOGTAG, "unable to delete temporary file");
1714                    }
1715                    return false;
1716                }
1717                avatar.size = bytes.length;
1718            } catch (IllegalArgumentException | IOException | NoSuchAlgorithmException e) {
1719                return false;
1720            } finally {
1721                close(os);
1722            }
1723        }
1724        return true;
1725    }
1726
1727    public void deleteHistoricAvatarPath() {
1728        delete(getHistoricAvatarPath());
1729    }
1730
1731    private void delete(final File file) {
1732        if (file.isDirectory()) {
1733            final File[] files = file.listFiles();
1734            if (files != null) {
1735                for (final File f : files) {
1736                    delete(f);
1737                }
1738            }
1739        }
1740        if (file.delete()) {
1741            Log.d(Config.LOGTAG, "deleted " + file.getAbsolutePath());
1742        }
1743    }
1744
1745    private File getHistoricAvatarPath() {
1746        return new File(mXmppConnectionService.getFilesDir(), "/avatars/");
1747    }
1748
1749    public File getAvatarFile(String avatar) {
1750        return new File(mXmppConnectionService.getCacheDir(), "/avatars/" + avatar);
1751    }
1752
1753    public Uri getAvatarUri(String avatar) {
1754        return Uri.fromFile(getAvatarFile(avatar));
1755    }
1756
1757    public Drawable cropCenterSquareDrawable(Uri image, int size) {
1758        if (android.os.Build.VERSION.SDK_INT >= 28) {
1759            try {
1760                ImageDecoder.Source source = ImageDecoder.createSource(mXmppConnectionService.getContentResolver(), image);
1761                return ImageDecoder.decodeDrawable(source, (decoder, info, src) -> {
1762                    int w = info.getSize().getWidth();
1763                    int h = info.getSize().getHeight();
1764                    Rect r = rectForSize(w, h, size);
1765                    decoder.setTargetSize(r.width(), r.height());
1766
1767                    int newSize = Math.min(r.width(), r.height());
1768                    int left = (r.width() - newSize) / 2;
1769                    int top = (r.height() - newSize) / 2;
1770                    decoder.setCrop(new Rect(left, top, left + newSize, top + newSize));
1771                });
1772            } catch (final IOException e) {
1773                return getSVGSquare(image, size);
1774            }
1775        } else {
1776            Bitmap bitmap = cropCenterSquare(image, size);
1777            return bitmap == null ? null : new BitmapDrawable(bitmap);
1778        }
1779    }
1780
1781    public Bitmap cropCenterSquare(Uri image, int size) {
1782        if (image == null) {
1783            return null;
1784        }
1785        InputStream is = null;
1786        try {
1787            BitmapFactory.Options options = new BitmapFactory.Options();
1788            options.inSampleSize = calcSampleSize(image, size);
1789            is = mXmppConnectionService.getContentResolver().openInputStream(image);
1790            if (is == null) {
1791                return null;
1792            }
1793            Bitmap input = BitmapFactory.decodeStream(is, null, options);
1794            if (input == null) {
1795                return null;
1796            } else {
1797                input = rotate(input, getRotation(image));
1798                return cropCenterSquare(input, size);
1799            }
1800        } catch (FileNotFoundException | SecurityException e) {
1801            Log.d(Config.LOGTAG, "unable to open file " + image.toString(), e);
1802            return null;
1803        } finally {
1804            close(is);
1805        }
1806    }
1807
1808    public Bitmap cropCenter(Uri image, int newHeight, int newWidth) {
1809        if (image == null) {
1810            return null;
1811        }
1812        InputStream is = null;
1813        try {
1814            BitmapFactory.Options options = new BitmapFactory.Options();
1815            options.inSampleSize = calcSampleSize(image, Math.max(newHeight, newWidth));
1816            is = mXmppConnectionService.getContentResolver().openInputStream(image);
1817            if (is == null) {
1818                return null;
1819            }
1820            Bitmap source = BitmapFactory.decodeStream(is, null, options);
1821            if (source == null) {
1822                return null;
1823            }
1824            int sourceWidth = source.getWidth();
1825            int sourceHeight = source.getHeight();
1826            float xScale = (float) newWidth / sourceWidth;
1827            float yScale = (float) newHeight / sourceHeight;
1828            float scale = Math.max(xScale, yScale);
1829            float scaledWidth = scale * sourceWidth;
1830            float scaledHeight = scale * sourceHeight;
1831            float left = (newWidth - scaledWidth) / 2;
1832            float top = (newHeight - scaledHeight) / 2;
1833
1834            RectF targetRect = new RectF(left, top, left + scaledWidth, top + scaledHeight);
1835            Bitmap dest = Bitmap.createBitmap(newWidth, newHeight, Bitmap.Config.ARGB_8888);
1836            Canvas canvas = new Canvas(dest);
1837            canvas.drawBitmap(source, null, targetRect, createAntiAliasingPaint());
1838            if (source.isRecycled()) {
1839                source.recycle();
1840            }
1841            return dest;
1842        } catch (SecurityException e) {
1843            return null; // android 6.0 with revoked permissions for example
1844        } catch (FileNotFoundException e) {
1845            return null;
1846        } finally {
1847            close(is);
1848        }
1849    }
1850
1851    public Bitmap cropCenterSquare(Bitmap input, int size) {
1852        int w = input.getWidth();
1853        int h = input.getHeight();
1854
1855        float scale = Math.max((float) size / h, (float) size / w);
1856
1857        float outWidth = scale * w;
1858        float outHeight = scale * h;
1859        float left = (size - outWidth) / 2;
1860        float top = (size - outHeight) / 2;
1861        RectF target = new RectF(left, top, left + outWidth, top + outHeight);
1862
1863        Bitmap output = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888);
1864        Canvas canvas = new Canvas(output);
1865        canvas.drawBitmap(input, null, target, createAntiAliasingPaint());
1866        if (!input.isRecycled()) {
1867            input.recycle();
1868        }
1869        return output;
1870    }
1871
1872    private int calcSampleSize(Uri image, int size)
1873            throws FileNotFoundException, SecurityException {
1874        final BitmapFactory.Options options = new BitmapFactory.Options();
1875        options.inJustDecodeBounds = true;
1876        final InputStream inputStream =
1877                mXmppConnectionService.getContentResolver().openInputStream(image);
1878        BitmapFactory.decodeStream(inputStream, null, options);
1879        close(inputStream);
1880        return calcSampleSize(options, size);
1881    }
1882
1883    public void updateFileParams(Message message) {
1884        updateFileParams(message, null);
1885    }
1886
1887    public void updateFileParams(final Message message, final String url) {
1888        updateFileParams(message, url, true);
1889    }
1890
1891    public void updateFileParams(final Message message, String url, boolean updateCids) {
1892        final boolean encrypted =
1893                message.getEncryption() == Message.ENCRYPTION_PGP
1894                        || message.getEncryption() == Message.ENCRYPTION_DECRYPTED;
1895        final DownloadableFile file = getFile(message);
1896        final String mime = file.getMimeType();
1897        final boolean privateMessage = message.isPrivateMessage();
1898        final boolean image =
1899                message.getType() == Message.TYPE_IMAGE
1900                        || (mime != null && mime.startsWith("image/"));
1901        Message.FileParams fileParams = message.getFileParams();
1902        if (fileParams == null) fileParams = new Message.FileParams();
1903        Cid[] cids = new Cid[0];
1904        try {
1905            cids = calculateCids(new FileInputStream(file));
1906            fileParams.setCids(List.of(cids));
1907        } catch (final IOException | NoSuchAlgorithmException e) { }
1908        if (url == null) {
1909            for (Cid cid : cids) {
1910                url = mXmppConnectionService.getUrlForCid(cid);
1911                if (url != null) {
1912                    fileParams.url = url;
1913                    break;
1914                }
1915            }
1916        } else {
1917            fileParams.url = url;
1918        }
1919        if (fileParams.getName() == null) fileParams.setName(file.getName());
1920        fileParams.setMediaType(mime);
1921        if (encrypted && !file.exists()) {
1922            Log.d(Config.LOGTAG, "skipping updateFileParams because file is encrypted");
1923            final DownloadableFile encryptedFile = getFile(message, false);
1924            fileParams.size = encryptedFile.getSize();
1925        } else {
1926            Log.d(Config.LOGTAG, "running updateFileParams");
1927            final boolean ambiguous = MimeUtils.AMBIGUOUS_CONTAINER_FORMATS.contains(mime);
1928            final boolean video = mime != null && mime.startsWith("video/");
1929            final boolean audio = mime != null && mime.startsWith("audio/");
1930            final boolean pdf = "application/pdf".equals(mime);
1931            fileParams.size = file.getSize();
1932            if (ambiguous) {
1933                try {
1934                    final Dimensions dimensions = getVideoDimensions(file);
1935                    if (dimensions.valid()) {
1936                        Log.d(Config.LOGTAG, "ambiguous file " + mime + " is video");
1937                        fileParams.width = dimensions.width;
1938                        fileParams.height = dimensions.height;
1939                    } else {
1940                        Log.d(Config.LOGTAG, "ambiguous file " + mime + " is audio");
1941                        fileParams.runtime = getMediaRuntime(file);
1942                    }
1943                } catch (final IOException | NotAVideoFile e) {
1944                    Log.d(Config.LOGTAG, "ambiguous file " + mime + " is audio");
1945                    fileParams.runtime = getMediaRuntime(file);
1946                }
1947            } else if (image || video || pdf) {
1948                try {
1949                    final Dimensions dimensions;
1950                    if (video) {
1951                        dimensions = getVideoDimensions(file);
1952                    } else if (pdf) {
1953                        dimensions = getPdfDocumentDimensions(file);
1954                    } else if ("image/svg+xml".equals(mime)) {
1955                        SVG svg = SVG.getFromInputStream(new FileInputStream(file));
1956                        dimensions = new Dimensions((int) svg.getDocumentHeight(), (int) svg.getDocumentWidth());
1957                    } else {
1958                        dimensions = getImageDimensions(file);
1959                    }
1960                    if (dimensions.valid()) {
1961                        fileParams.width = dimensions.width;
1962                        fileParams.height = dimensions.height;
1963                    }
1964                } catch (final IOException | SVGParseException | NotAVideoFile notAVideoFile) {
1965                    Log.d(
1966                            Config.LOGTAG,
1967                            "file with mime type " + file.getMimeType() + " was not a video file");
1968                    // fall threw
1969                }
1970            } else if (audio) {
1971                fileParams.runtime = getMediaRuntime(file);
1972            }
1973            try {
1974                Bitmap thumb = getThumbnailBitmap(file, mXmppConnectionService.getResources(), 100, file.getAbsolutePath() + " x 100");
1975                if (thumb != null) {
1976                    int[] pixels = new int[thumb.getWidth() * thumb.getHeight()];
1977                    byte[] rgba = new byte[pixels.length * 4];
1978                    try {
1979                        thumb.getPixels(pixels, 0, thumb.getWidth(), 0, 0, thumb.getWidth(), thumb.getHeight());
1980                    } catch (final IllegalStateException e) {
1981                        Bitmap softThumb = thumb.copy(Bitmap.Config.ARGB_8888, false);
1982                        softThumb.getPixels(pixels, 0, thumb.getWidth(), 0, 0, thumb.getWidth(), thumb.getHeight());
1983                        softThumb.recycle();
1984                    }
1985                    for (int i = 0; i < pixels.length; i++) {
1986                        rgba[i*4] = (byte)((pixels[i] >> 16) & 0xff);
1987                        rgba[(i*4)+1] = (byte)((pixels[i] >> 8) & 0xff);
1988                        rgba[(i*4)+2] = (byte)(pixels[i] & 0xff);
1989                        rgba[(i*4)+3] = (byte)((pixels[i] >> 24) & 0xff);
1990                    }
1991                    fileParams.addThumbnail(thumb.getWidth(), thumb.getHeight(), "image/thumbhash", "data:image/thumbhash;base64," + Base64.encodeToString(ThumbHash.rgbaToThumbHash(thumb.getWidth(), thumb.getHeight(), rgba), Base64.NO_WRAP));
1992                }
1993            } catch (final IOException e) { }
1994        }
1995        message.setFileParams(fileParams);
1996        message.setDeleted(false);
1997        message.setType(
1998                privateMessage
1999                        ? Message.TYPE_PRIVATE_FILE
2000                        : (image ? Message.TYPE_IMAGE : Message.TYPE_FILE));
2001
2002        if (updateCids) {
2003            try {
2004                for (int i = 0; i < cids.length; i++) {
2005                    mXmppConnectionService.saveCid(cids[i], file);
2006                }
2007            } catch (XmppConnectionService.BlockedMediaException e) { }
2008        }
2009    }
2010
2011    private int getMediaRuntime(final File file) {
2012        try {
2013            final MediaMetadataRetriever mediaMetadataRetriever = new MediaMetadataRetriever();
2014            mediaMetadataRetriever.setDataSource(file.toString());
2015            final String value =
2016                    mediaMetadataRetriever.extractMetadata(
2017                            MediaMetadataRetriever.METADATA_KEY_DURATION);
2018            if (Strings.isNullOrEmpty(value)) {
2019                return 0;
2020            }
2021            return Integer.parseInt(value);
2022        } catch (final Exception e) {
2023            return 0;
2024        }
2025    }
2026
2027    private Dimensions getImageDimensions(File file) {
2028        final BitmapFactory.Options options = new BitmapFactory.Options();
2029        options.inJustDecodeBounds = true;
2030        BitmapFactory.decodeFile(file.getAbsolutePath(), options);
2031        final int rotation = getRotation(file);
2032        final boolean rotated = rotation == 90 || rotation == 270;
2033        final int imageHeight = rotated ? options.outWidth : options.outHeight;
2034        final int imageWidth = rotated ? options.outHeight : options.outWidth;
2035        return new Dimensions(imageHeight, imageWidth);
2036    }
2037
2038    private Dimensions getVideoDimensions(final File file) throws NotAVideoFile, IOException {
2039        MediaMetadataRetriever metadataRetriever = new MediaMetadataRetriever();
2040        try {
2041            metadataRetriever.setDataSource(file.getAbsolutePath());
2042        } catch (RuntimeException e) {
2043            throw new NotAVideoFile(e);
2044        }
2045        return getVideoDimensions(metadataRetriever);
2046    }
2047
2048    private Dimensions getPdfDocumentDimensions(final File file) {
2049        final ParcelFileDescriptor fileDescriptor;
2050        try {
2051            fileDescriptor = ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY);
2052            if (fileDescriptor == null) {
2053                return new Dimensions(0, 0);
2054            }
2055        } catch (final FileNotFoundException e) {
2056            return new Dimensions(0, 0);
2057        }
2058        try {
2059            final PdfRenderer pdfRenderer = new PdfRenderer(fileDescriptor);
2060            final PdfRenderer.Page page = pdfRenderer.openPage(0);
2061            final int height = page.getHeight();
2062            final int width = page.getWidth();
2063            page.close();
2064            pdfRenderer.close();
2065            return scalePdfDimensions(new Dimensions(height, width));
2066        } catch (final IOException | SecurityException e) {
2067            Log.d(Config.LOGTAG, "unable to get dimensions for pdf document", e);
2068            return new Dimensions(0, 0);
2069        }
2070    }
2071
2072    private Dimensions scalePdfDimensions(Dimensions in) {
2073        final DisplayMetrics displayMetrics =
2074                mXmppConnectionService.getResources().getDisplayMetrics();
2075        final int target = (int) (displayMetrics.density * 288);
2076        return scalePdfDimensions(in, target, true);
2077    }
2078
2079    private static Dimensions scalePdfDimensions(
2080            final Dimensions in, final int target, final boolean fit) {
2081        final int w, h;
2082        if (fit == (in.width <= in.height)) {
2083            w = Math.max((int) (in.width / ((double) in.height / target)), 1);
2084            h = target;
2085        } else {
2086            w = target;
2087            h = Math.max((int) (in.height / ((double) in.width / target)), 1);
2088        }
2089        return new Dimensions(h, w);
2090    }
2091
2092    public Drawable getAvatar(String avatar, int size) {
2093        if (avatar == null) {
2094            return null;
2095        }
2096
2097        if (android.os.Build.VERSION.SDK_INT >= 28) {
2098            try {
2099                ImageDecoder.Source source = ImageDecoder.createSource(getAvatarFile(avatar));
2100                return ImageDecoder.decodeDrawable(source, (decoder, info, src) -> {
2101                    int w = info.getSize().getWidth();
2102                    int h = info.getSize().getHeight();
2103                    Rect r = rectForSize(w, h, size);
2104                    decoder.setTargetSize(r.width(), r.height());
2105
2106                    int newSize = Math.min(r.width(), r.height());
2107                    int left = (r.width() - newSize) / 2;
2108                    int top = (r.height() - newSize) / 2;
2109                    decoder.setCrop(new Rect(left, top, left + newSize, top + newSize));
2110                });
2111            } catch (final IOException e) {
2112                return getSVGSquare(getAvatarUri(avatar), size);
2113            }
2114        } else {
2115            Bitmap bm = cropCenter(getAvatarUri(avatar), size, size);
2116            return bm == null ? null : new BitmapDrawable(bm);
2117        }
2118    }
2119
2120    public Drawable getSVGSquare(Uri uri, int size) {
2121        try {
2122            SVG svg = SVG.getFromInputStream(mXmppConnectionService.getContentResolver().openInputStream(uri));
2123            svg.setDocumentPreserveAspectRatio(com.caverock.androidsvg.PreserveAspectRatio.FULLSCREEN);
2124            svg.setDocumentWidth("100%");
2125            svg.setDocumentHeight("100%");
2126
2127            float w = svg.getDocumentWidth();
2128            float h = svg.getDocumentHeight();
2129            float scale = Math.max((float) size / h, (float) size / w);
2130            float outWidth = scale * w;
2131            float outHeight = scale * h;
2132            float left = (size - outWidth) / 2;
2133            float top = (size - outHeight) / 2;
2134            RectF target = new RectF(left, top, left + outWidth, top + outHeight);
2135
2136            Bitmap output = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888);
2137            Canvas canvas = new Canvas(output);
2138            svg.renderToCanvas(canvas, target);
2139
2140            return new SVGDrawable(output);
2141        } catch (final IOException | SVGParseException e) {
2142            return null;
2143        }
2144    }
2145
2146    public Drawable getSVG(File file, int size) {
2147        try {
2148            SVG svg = SVG.getFromInputStream(new FileInputStream(file));
2149            svg.setDocumentPreserveAspectRatio(com.caverock.androidsvg.PreserveAspectRatio.TOP);
2150
2151            float w = svg.getDocumentWidth();
2152            float h = svg.getDocumentHeight();
2153            Rect r = rectForSize((int) w, (int) h, size);
2154            svg.setDocumentWidth("100%");
2155            svg.setDocumentHeight("100%");
2156
2157            Bitmap output = Bitmap.createBitmap(r.width(), r.height(), Bitmap.Config.ARGB_8888);
2158            Canvas canvas = new Canvas(output);
2159            svg.renderToCanvas(canvas);
2160
2161            return new SVGDrawable(output);
2162        } catch (final IOException | SVGParseException e) {
2163            return null;
2164        }
2165    }
2166
2167    private static class Dimensions {
2168        public final int width;
2169        public final int height;
2170
2171        Dimensions(int height, int width) {
2172            this.width = width;
2173            this.height = height;
2174        }
2175
2176        public int getMin() {
2177            return Math.min(width, height);
2178        }
2179
2180        public boolean valid() {
2181            return width > 0 && height > 0;
2182        }
2183    }
2184
2185    private static class NotAVideoFile extends Exception {
2186        public NotAVideoFile(Throwable t) {
2187            super(t);
2188        }
2189
2190        public NotAVideoFile() {
2191            super();
2192        }
2193    }
2194
2195    public static class ImageCompressionException extends Exception {
2196
2197        ImageCompressionException(String message) {
2198            super(message);
2199        }
2200    }
2201
2202    public static class FileCopyException extends Exception {
2203        private final int resId;
2204
2205        private FileCopyException(@StringRes int resId) {
2206            this.resId = resId;
2207        }
2208
2209        public @StringRes int getResId() {
2210            return resId;
2211        }
2212    }
2213
2214    public static class SVGDrawable extends BitmapDrawable {
2215        public SVGDrawable(Bitmap bm) { super(bm); }
2216    }
2217}