Commit e13b6568 authored by Bartek Fabiszewski's avatar Bartek Fabiszewski
Browse files

Handle image processing in separate thread, minor cleanups

parent 60f2bdbc
Loading
Loading
Loading
Loading
+2 −4
Original line number Diff line number Diff line
@@ -45,10 +45,8 @@ android {

dependencies {
    implementation fileTree(include: ['*.jar'], dir: 'libs')
    implementation 'androidx.appcompat:appcompat:1.1.0-beta01'
    implementation 'androidx.preference:preference:1.1.0-beta01'
    implementation 'androidx.appcompat:appcompat:1.1.0-rc01'
    implementation 'androidx.preference:preference:1.1.0-rc01'
    implementation 'androidx.exifinterface:exifinterface:1.0.0'
    implementation 'androidx.legacy:legacy-support-v4:1.0.0'
    testImplementation 'junit:junit:4.12'
    implementation 'androidx.lifecycle:lifecycle-extensions:2.0.0'
}
+31 −1
Original line number Diff line number Diff line
@@ -267,7 +267,7 @@ class DbAccess implements AutoCloseable {
                new String[]{String.valueOf(id)});
        Uri uri = getImageUri(id);
        if (uri != null) {
            ImageHelper.deleteImage(context, uri);
            ImageHelper.deleteLocalImage(context, uri);
        }
    }

@@ -311,6 +311,36 @@ class DbAccess implements AutoCloseable {
        }
    }

    /**
     * Get number of not synchronized items.
     *
     * @return Count
     */
    private int countImages() {
        Cursor count = db.query(DbContract.Positions.TABLE_NAME,
                new String[]{"COUNT(*)"},
                DbContract.Positions.COLUMN_IMAGE_URI + " IS NOT NULL",
                null, null, null, null);
        int result = 0;
        if (count.moveToFirst()) {
            result = count.getInt(0);
        }
        count.close();
        return result;
    }

    /**
     * Get number of not synchronized items.
     *
     * @param context Context
     * @return Count
     */
    static int countImages(Context context) {
        try (DbAccess dbAccess = getOpenInstance(context)) {
            return dbAccess.countImages();
        }
    }

    /**
     * Checks if database needs synchronization,
     * i.e. contains non-synchronized positions.
+169 −40
Original line number Diff line number Diff line
@@ -12,24 +12,25 @@ package net.fabiszewski.ulogger;
import android.content.ContentResolver;
import android.content.ContentValues;
import android.content.Context;
import android.content.SharedPreferences;
import android.content.Intent;
import android.content.res.Resources;
import android.database.Cursor;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.graphics.Matrix;
import android.media.MediaScannerConnection;
import android.media.ThumbnailUtils;
import android.net.Uri;
import android.os.Build;
import android.provider.MediaStore;
import android.provider.OpenableColumns;
import android.util.Log;
import android.util.Size;
import android.webkit.MimeTypeMap;

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.exifinterface.media.ExifInterface;
import androidx.preference.PreferenceManager;

import java.io.File;
import java.io.FileOutputStream;
@@ -47,6 +48,7 @@ class ImageHelper {
    private static final int ORIENTATION_180 = 180;
    private static final int ORIENTATION_270 = 270;
    private static final int ORIENTATION_NORMAL = 0;
    private static final String EXT_JPG = ".jpg";


    /**
@@ -57,7 +59,7 @@ class ImageHelper {
     * @param uri     Image URI
     * @return Orientation in degrees
     */
    private static int getOrientation(Context context, Uri uri) {
    private static int getOrientation(@NonNull Context context, @NonNull Uri uri) {
        int orientation = getOrientationMediaStore(context, uri);
        if (orientation == 0) {
            orientation = getOrientationExif(context, uri);
@@ -72,7 +74,7 @@ class ImageHelper {
     * @param uri     Image URI
     * @return Orientation in degrees
     */
    private static int getOrientationExif(Context context, Uri uri) {
    private static int getOrientationExif(@NonNull Context context, @NonNull Uri uri) {
        int orientation = ORIENTATION_NORMAL;
        try (InputStream in = context.getContentResolver().openInputStream(uri)) {
            if (in != null) {
@@ -99,10 +101,10 @@ class ImageHelper {
     * @param uri     Image URI
     * @return Orientation in degrees
     */
    private static int getOrientationMediaStore(Context context, Uri photoUri) {
    private static int getOrientationMediaStore(@NonNull Context context, @NonNull Uri uri) {
        int orientation = 0;
        String[] projection = {MEDIA_ORIENTATION};
        try (Cursor cursor = context.getContentResolver().query(photoUri, projection, null, null, null)) {
        try (Cursor cursor = context.getContentResolver().query(uri, projection, null, null, null)) {
            if (cursor != null && cursor.moveToFirst()) {
                orientation = cursor.getInt(0);
            }
@@ -123,16 +125,35 @@ class ImageHelper {
        return "ulogger_" + timeStamp;
    }

    /**
     * Creates media title based on track media count
     *
     * @return Name
     */
    @NonNull
    private static String getUniqueTitle(@NonNull Context context) {
        int imageNumber = DbAccess.countImages(context) + 1;
        String trackName = DbAccess.getTrackName(context);
        return String.format(Locale.ROOT,"%s #%d", trackName, imageNumber);
    }

    /**
     * Create image uri in media collection
     *
     * @param context Context
     * @return URI
     */
    static Uri createImageUri(Context context) {
    static Uri createImageUri(@NonNull Context context) {
        ContentValues values = new ContentValues();
        values.put(MediaStore.Images.Media.TITLE, getUniqueName());
        String title = getUniqueTitle(context);
        long timeMillis = System.currentTimeMillis();
        values.put(MediaStore.Images.Media.TITLE, title);
        values.put(MediaStore.Images.Media.DISPLAY_NAME, title);
        values.put(MediaStore.Images.Media.MIME_TYPE, JPEG_MIME);
        values.put(MediaStore.Images.Media.DATE_ADDED, timeMillis / 1000);
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
            values.put(MediaStore.Images.Media.DATE_TAKEN, timeMillis);
        }
        Uri collection;
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
            collection = MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY);
@@ -142,23 +163,61 @@ class ImageHelper {
        return context.getContentResolver().insert(collection, values);
    }


    static Bitmap getThumbnail(Context context, Uri uri) throws IOException {
        int sizeDp = (int) context.getResources().getDimension(R.dimen.thumbnail_size);
        int sizePx = sizeDp * (int) Resources.getSystem().getDisplayMetrics().density;

    /**
     * Extract thumbnail from URI
     * @param context Context
     * @param uri URI
     * @return Thumbnail
     * @throws IOException IO exception on failure
     */
    static Bitmap getThumbnail(@NonNull Context context, @NonNull Uri uri) throws IOException {
        int sizePx = getThumbnailSize(context);
        Bitmap bitmap;
        ContentResolver cr = context.getContentResolver();
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
            bitmap = cr.loadThumbnail(uri, new Size(sizePx, sizePx), null);
        } else {
            try (InputStream is = cr.openInputStream(uri)) {
                bitmap = BitmapFactory.decodeStream(is, null, null);
            }

            bitmap = ThumbnailUtils.extractThumbnail(bitmap, sizePx, sizePx);
        }
        bitmap = fixImageOrientation(context, uri, bitmap);
        return bitmap;
    }

    private static Bitmap fixImageOrientation(Context context, Uri uri, Bitmap bitmap) {
    /**
     * Extract thumbnail from URI
     * @param context Context
     * @param uri URI
     * @return Thumbnail
     */
    static Bitmap getThumbnail(@NonNull Context context, @NonNull Bitmap bitmap) {
        int sizePx = getThumbnailSize(context);
        return ThumbnailUtils.extractThumbnail(bitmap, sizePx, sizePx);
    }

    /**
     * Get thumbnail size from resources
     * @param context Context
     * @return Size in pixels
     */
    private static int getThumbnailSize(@NonNull Context context) {
        int sizeDp = (int) context.getResources().getDimension(R.dimen.thumbnail_size);
        int sizePx = sizeDp * (int) Resources.getSystem().getDisplayMetrics().density;
        if (Logger.DEBUG) { Log.d(TAG, "[getThumbnailSize: " + sizePx + "]" ); }
        return sizePx;
    }

    /**
     * Fix image orientation if needed
     * @param context Context
     * @param uri Image URI as source of orientation data (MediaStore or EXIF)
     * @param bitmap Bitmap to be rotated
     * @return Resulting bitmap
     */
    private static Bitmap fixImageOrientation(@NonNull Context context, @NonNull Uri uri, @NonNull Bitmap bitmap) {
        try {
            int orientation = ImageHelper.getOrientation(context, uri);
            if (orientation != 0) {
@@ -172,7 +231,13 @@ class ImageHelper {
        return bitmap;
    }

    static long getFileSize(Context context, Uri uri) {
    /**
     * Get file size
     * @param context Context
     * @param uri File URI
     * @return Size
     */
    static long getFileSize(@NonNull Context context, @NonNull Uri uri) {
        long fileSize = 0;
        try (Cursor cursor = context.getContentResolver()
                .query(uri, null, null, null, null)) {
@@ -185,23 +250,32 @@ class ImageHelper {
        return fileSize;
    }

    /**
     * Try to get file MIME type
     * @param context Context
     * @param uri File URI
     * @return MIME type or null if not known
     */
    @Nullable
    static String getFileMime(Context context, Uri uri) {
    static String getFileMime(@NonNull Context context, @NonNull Uri uri) {
        ContentResolver cr = context.getContentResolver();
        String fileMime = cr.getType(uri);
        if (fileMime == null) {
            String fileExtension = MimeTypeMap.getFileExtensionFromUrl(uri.toString());
            fileMime = MimeTypeMap.getSingleton().getMimeTypeFromExtension(fileExtension.toLowerCase());
            fileMime = MimeTypeMap.getSingleton().getMimeTypeFromExtension(fileExtension.toLowerCase(Locale.ROOT));
        }
        return fileMime;
    }

    static Uri resampleIfNeeded(Context context, Uri uri) throws IOException {
        SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context);
        int dstWidth = Integer.parseInt(prefs.getString(SettingsActivity.KEY_IMAGE_SIZE, context.getString(R.string.pref_imagesize_default)));
        if (dstWidth == 0) {
            return uri;
        }
    /**
     * Resample image to given size threshold
     * @param context Context
     * @param uri Image URI
     * @param dstWidth Maximum width/height
     * @return Resampled bitmap
     * @throws IOException IO exception on error
     */
    static Bitmap getResampledBitmap(@NonNull Context context, @NonNull Uri uri, int dstWidth) throws IOException {
        ContentResolver cr = context.getContentResolver();
        BitmapFactory.Options options = new BitmapFactory.Options();
        options.inJustDecodeBounds = true;
@@ -227,12 +301,13 @@ class ImageHelper {
                }
            } catch (OutOfMemoryError e) {
                if (Logger.DEBUG) { Log.d(TAG, "[resampleIfNeeded OutOfMemoryError]"); }

            }
            if (bitmap == null && scale > 1) {
                if (retry) {
                    throw new IOException("Out of memory");
                } else if (scale > 1) {
                    retry = true;
                    options.inSampleSize = scale;
                retry = !retry;
                if (Logger.DEBUG) { Log.d(TAG, "[resampleIfNeeded try sampling: " + retry + "]"); }
                    if (Logger.DEBUG) { Log.d(TAG, "[resampleIfNeeded try sampling]"); }
                }
            }
        } while (retry);

@@ -241,8 +316,18 @@ class ImageHelper {
        }

        bitmap = fixImageOrientation(context, uri, bitmap);
        return bitmap;
    }

        String filename = getUniqueName() + ".jpg";
    /**
     * Save bitmap to app cache folder as jpeg
     * @param context Context
     * @param bitmap Bitmap
     * @return URI of saved image
     * @throws IOException IO exception on failure
     */
    static Uri saveToCache(@NonNull Context context, @NonNull Bitmap bitmap) throws IOException {
        String filename = getUniqueName() + EXT_JPG;
        File outFile = new File(context.getCacheDir(), filename);
        try (FileOutputStream os = new FileOutputStream(outFile)) {
            bitmap.compress(Bitmap.CompressFormat.JPEG, 90, os);
@@ -250,7 +335,14 @@ class ImageHelper {
        return Uri.fromFile(outFile);
    }

    static Uri moveToAppStorage(@NonNull Context context, @NonNull Uri inUri) {
    /**
     * Move cached image file to internal app folder
     * Ignore files that are not in cache folder
     * @param context Context
     * @param inUri Source URI
     * @return Destination URI
     */
    static Uri moveCachedToAppStorage(@NonNull Context context, @NonNull Uri inUri) {
        Uri outUri = null;
        String path = inUri.getPath();
        if (path != null) {
@@ -262,40 +354,77 @@ class ImageHelper {
            if (inFile.renameTo(outFile)) {
                outUri = Uri.fromFile(outFile);
            } else {
                if (Logger.DEBUG) { Log.d(TAG, "[moveToAppStorage failed]"); }
                if (Logger.DEBUG) { Log.d(TAG, "[moveCachedToAppStorage failed]"); }
            }
        }
        return outUri;
    }

    /**
     * Clear images in cache folder
     * @param context Context
     */
    static void clearImageCache(@NonNull Context context) {
        File dir = context.getCacheDir();
        clearImages(dir);
    }

    /**
     * Clear images in app folder
     * @param context Context
     */
    static void clearTrackImages(@NonNull Context context) {
        File dir = context.getFilesDir();
        clearImages(dir);
    }

    private static void clearImages(File dir) {
    /**
     * Clear image jpeg files in given folder
     * @param dir Folder
     */
    private static void clearImages(@NonNull File dir) {
        File[] files = dir.listFiles();
        if (files != null) {
            for (File file : files) {
                if (file.isFile() && file.getPath().endsWith(".jpg") && file.delete()) {
                if (file.isFile() && file.getPath().endsWith(EXT_JPG) && file.delete()) {
                    if (Logger.DEBUG) { Log.d(TAG, "[clearImages deleted file " + file.getName() + "]"); }
                }
            }
        }
    }

    static void deleteImage(Context context, Uri uri) {
    /**
     * Delete file only if it is located in app internal folder
     * @param context Context
     * @param uri File URI
     */
    static void deleteLocalImage(@NonNull Context context, @NonNull Uri uri) {
        String path = uri.getPath();
        if (path != null) {
            File file = new File(path);
            if (file.isFile() && file.getPath().startsWith(context.getFilesDir().getPath()) && file.delete()) {
                if (Logger.DEBUG) { Log.d(TAG, "[deleteImage deleted file " + file.getName() + "]"); }
                if (Logger.DEBUG) { Log.d(TAG, "[deleteLocalImage deleted file " + file.getName() + "]"); }
            }
        }
    }

    /**
     * Add image to MediaStore
     * @param context Context
     * @param uri Image URI
     */
    static void galleryAdd(@NonNull Context context, @NonNull Uri uri) {
        ContentResolver cr = context.getContentResolver();
        String mime = cr.getType(uri);
        MediaScannerConnection.scanFile(context, new String[] {uri.getPath()}, new String[] {mime}, null);
    }

    /**
     * Get persistable permission for URI
     * @param context Context
     * @param uri URI
     */
    static void getPersistablePermision(@NonNull Context context, @NonNull Uri uri) {
        context.getContentResolver().takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION);
    }
}
+120 −0
Original line number Diff line number Diff line
/*
 * Copyright (c) 2019 Bartek Fabiszewski
 * http://www.fabiszewski.net
 *
 * This file is part of μlogger-android.
 * Licensed under GPL, either version 3, or any later.
 * See <http://www.gnu.org/licenses/>
 */

package net.fabiszewski.ulogger;

import android.app.Activity;
import android.content.SharedPreferences;
import android.graphics.Bitmap;
import android.net.Uri;
import android.os.AsyncTask;
import android.util.Log;

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.preference.PreferenceManager;

import java.io.IOException;
import java.lang.ref.WeakReference;

import static net.fabiszewski.ulogger.ImageHelper.getPersistablePermision;
import static net.fabiszewski.ulogger.ImageHelper.getResampledBitmap;
import static net.fabiszewski.ulogger.ImageHelper.getThumbnail;
import static net.fabiszewski.ulogger.ImageHelper.saveToCache;


/**
 * Task to downsample image
 */
class ImageTask extends AsyncTask<Uri, Void, ImageTask.ImageTaskResult> {

    private static final String TAG = ImageTask.class.getSimpleName();

    private final WeakReference<ImageTaskCallback> weakCallback;

    private String errorMessage = "Image resampling failed";

    ImageTask(ImageTaskCallback callback) {
        weakCallback = new WeakReference<>(callback);
    }

    @Override
    protected ImageTaskResult doInBackground(Uri... params) {
        if (Logger.DEBUG) { Log.d(TAG, "[doInBackground]"); }
        Activity activity = getActivity();
        if (activity == null || params.length != 1 || params[0] == null) {
            return null;
        }
        Uri inUri = params[0];
        ImageTaskResult result = null;
        try {
            Uri savedUri;
            Bitmap thumbnail;

            SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(activity);
            int dstWidth = Integer.parseInt(prefs.getString(SettingsActivity.KEY_IMAGE_SIZE, activity.getString(R.string.pref_imagesize_default)));
            if (dstWidth == 0) {
                savedUri = inUri;
                getPersistablePermision(activity, inUri);
                thumbnail = getThumbnail(activity, inUri);
            } else {
                Bitmap bitmap = getResampledBitmap(activity, inUri, dstWidth);
                savedUri = saveToCache(activity, bitmap);
                thumbnail = getThumbnail(activity, bitmap);
                bitmap.recycle();
            }
            if (savedUri != null && thumbnail != null) {
                result = new ImageTaskResult(savedUri, thumbnail);
            }
        } catch (IOException e) {
            if (e.getMessage() != null) {
                errorMessage += ": " + e.getMessage();
            }
        }
        return result;
    }

    @Override
    protected void onPostExecute(@Nullable ImageTaskResult result) {
        super.onPostExecute(result);
        ImageTaskCallback callback = weakCallback.get();
        if (callback != null) {
            if (result == null) {
                callback.onImageTaskFailure(errorMessage);
            } else {
                callback.onImageTaskCompleted(result.savedUri, result.thumbnail);
            }
        }
    }

    @Nullable
    private Activity getActivity() {
        ImageTaskCallback callback = weakCallback.get();
        if (callback != null) {
            return callback.getActivity();
        }
        return null;
    }

    interface ImageTaskCallback {
        void onImageTaskCompleted(@NonNull Uri uri, @NonNull Bitmap thumbnail);
        void onImageTaskFailure(@NonNull String error);
        Activity getActivity();
    }

    class ImageTaskResult {
        final Uri savedUri;
        final Bitmap thumbnail;

        ImageTaskResult(@NonNull Uri savedUri, @NonNull Bitmap thumbnail) {
            this.savedUri = savedUri;
            this.thumbnail = thumbnail;
        }
    }
}
+1 −1
Original line number Diff line number Diff line
@@ -102,7 +102,7 @@ class LocationFormatter {
        if (LocationHelper.isGps(location)) {
            provider = context.getString(R.string.provider_gps);
        } else if (LocationHelper.isNetwork(location)) {
            provider = context.getString(R.string.provider_network).toLowerCase();
            provider = context.getString(R.string.provider_network).toLowerCase(Locale.getDefault());
        } else {
            provider = location.getProvider();
        }
Loading