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