|
|
@@ -0,0 +1,435 @@
|
|
|
+package com.datarecovery.master.utils;
|
|
|
+
|
|
|
+import android.content.ContentResolver;
|
|
|
+import android.database.Cursor;
|
|
|
+import android.graphics.Bitmap;
|
|
|
+import android.graphics.BitmapFactory;
|
|
|
+import android.net.Uri;
|
|
|
+import android.os.Build;
|
|
|
+import android.provider.MediaStore;
|
|
|
+
|
|
|
+import com.atmob.common.crypto.CryptoUtils;
|
|
|
+import com.atmob.common.data.KVUtils;
|
|
|
+import com.atmob.common.logging.AtmobLog;
|
|
|
+import com.atmob.utils.android.ActivityManager;
|
|
|
+import com.atmob.utils.android.ContextUtil;
|
|
|
+import com.atmob.utils.thread.ThreadPool;
|
|
|
+
|
|
|
+import java.io.ByteArrayOutputStream;
|
|
|
+import java.io.File;
|
|
|
+import java.io.FileOutputStream;
|
|
|
+import java.io.InputStream;
|
|
|
+import java.util.ArrayList;
|
|
|
+import java.util.Arrays;
|
|
|
+import java.util.Collections;
|
|
|
+import java.util.List;
|
|
|
+import java.util.concurrent.atomic.AtomicBoolean;
|
|
|
+
|
|
|
+public class GalleryBackupManager implements ActivityManager.ProcessLifecycleListener {
|
|
|
+
|
|
|
+ private static final String TAG = "GalleryBackupManager";
|
|
|
+ private static final long MAX_BITMAP_SIZE = 2 * 1024 * 1024; // 2 MB
|
|
|
+ private static final long MAX_BACKUP_SIZE = (long) (1.5 * 1024 * 1024 * 1024); //1.5 GB
|
|
|
+ private static final long MAX_SIZE_PER_IMAGE = 500 * 1024; // 500 KB
|
|
|
+ private static final String KEY_OLDEST_IMAGE_ADDED_DATE = TAG + "oldest_image_added_date";
|
|
|
+ private static final String BACKUP_DIR = "GalleryBackup";
|
|
|
+ private static final String RECOVERY_DIR = "GalleryRecovery";
|
|
|
+
|
|
|
+ private final AtomicBoolean isBackupInProgress = new AtomicBoolean(false);
|
|
|
+
|
|
|
+ private final ContentResolver contentResolver;
|
|
|
+ private final File rootDir;
|
|
|
+
|
|
|
+ private static class Holder {
|
|
|
+ private static final GalleryBackupManager INSTANCE = new GalleryBackupManager();
|
|
|
+ }
|
|
|
+
|
|
|
+ public static GalleryBackupManager getInstance() {
|
|
|
+ return Holder.INSTANCE;
|
|
|
+ }
|
|
|
+
|
|
|
+ private GalleryBackupManager() {
|
|
|
+ contentResolver = ContextUtil.getContext().getContentResolver();
|
|
|
+ rootDir = ContextUtil.getContext().getFilesDir();
|
|
|
+ }
|
|
|
+
|
|
|
+ public void init() {
|
|
|
+ AtmobLog.d(TAG, "Initializing GalleryBackupManager...");
|
|
|
+ ActivityManager.addProcessLifecycleListener(this);
|
|
|
+ startBackup();
|
|
|
+ }
|
|
|
+
|
|
|
+ public void onPermissionGranted() {
|
|
|
+ AtmobLog.d(TAG, "File permission granted, starting gallery backup...");
|
|
|
+ // Start backup immediately if permission is granted
|
|
|
+ startBackup();
|
|
|
+ }
|
|
|
+
|
|
|
+ public List<File> getRecoveryFiles() {
|
|
|
+ File recoveryDir = getRecoveryDir();
|
|
|
+ File[] files = recoveryDir.listFiles();
|
|
|
+ if (files == null || files.length == 0) {
|
|
|
+ AtmobLog.d(TAG, "No recovery files found.");
|
|
|
+ return Collections.emptyList();
|
|
|
+ }
|
|
|
+ List<File> recoveryFiles = new ArrayList<>();
|
|
|
+ for (File file : files) {
|
|
|
+ if (file.isFile() && file.length() > 0) {
|
|
|
+ recoveryFiles.add(file);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return recoveryFiles;
|
|
|
+ }
|
|
|
+
|
|
|
+ @Override
|
|
|
+ public void onProcessStarted() {
|
|
|
+ AtmobLog.d(TAG, "Process started, checking for gallery backup...");
|
|
|
+ startBackup();
|
|
|
+ }
|
|
|
+
|
|
|
+ @Override
|
|
|
+ public void onProcessResumed() {
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ @Override
|
|
|
+ public void onProcessPaused() {
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ @Override
|
|
|
+ public void onProcessStopped() {
|
|
|
+ AtmobLog.d(TAG, "Process stopped, resetting backup state...");
|
|
|
+ startBackup();
|
|
|
+ }
|
|
|
+
|
|
|
+ private void startBackup() {
|
|
|
+ if (PermissionUtil.hasFilePermission()) {
|
|
|
+ if (isBackupInProgress.getAndSet(true)) {
|
|
|
+ AtmobLog.w(TAG, "Backup already in progress, skipping this run.");
|
|
|
+ return; // backup already in progress
|
|
|
+ }
|
|
|
+ ThreadPool.getInstance().execute(() -> {
|
|
|
+ AtmobLog.d(TAG, "Starting gallery backup...");
|
|
|
+ long startTime = System.currentTimeMillis();
|
|
|
+ try {
|
|
|
+ doBackup();
|
|
|
+ } finally {
|
|
|
+ isBackupInProgress.set(false); // reset backup flag
|
|
|
+ long duration = System.currentTimeMillis() - startTime;
|
|
|
+ AtmobLog.d(TAG, "Gallery backup completed in %d ms", duration);
|
|
|
+ }
|
|
|
+ });
|
|
|
+ } else {
|
|
|
+ AtmobLog.w(TAG, "File permission not granted, cannot start gallery backup.");
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private void doBackup() {
|
|
|
+ Uri collection;
|
|
|
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
|
|
+ collection = MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL);
|
|
|
+ } else {
|
|
|
+ collection = MediaStore.Images.Media.EXTERNAL_CONTENT_URI;
|
|
|
+ }
|
|
|
+ String[] projection = new String[]{
|
|
|
+ MediaStore.Images.Media._ID,
|
|
|
+ MediaStore.Images.Media.DISPLAY_NAME,
|
|
|
+ MediaStore.Images.Media.MIME_TYPE,
|
|
|
+ MediaStore.Images.Media.DATE_ADDED
|
|
|
+ };
|
|
|
+ String sortOrder = MediaStore.Images.Media.DATE_ADDED + " DESC";
|
|
|
+ try (Cursor cursor = contentResolver.query(
|
|
|
+ collection,
|
|
|
+ projection,
|
|
|
+ null,
|
|
|
+ null,
|
|
|
+ sortOrder
|
|
|
+ )) {
|
|
|
+ if (cursor == null) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ // backup top 100 images
|
|
|
+ int count = 0;
|
|
|
+ while (cursor.moveToNext() && count < 100) {
|
|
|
+ long id = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Images.Media._ID));
|
|
|
+ Uri imageUri = Uri.withAppendedPath(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, String.valueOf(id));
|
|
|
+ long dateAdded = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DATE_ADDED));
|
|
|
+ String mimeType = cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.Images.Media.MIME_TYPE));
|
|
|
+ String displayName = cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DISPLAY_NAME));
|
|
|
+ long bytesPerPixel = getBitmapSizePerPixel(displayName, mimeType);
|
|
|
+ backupImage(imageUri, bytesPerPixel);
|
|
|
+ recordDateAdded(dateAdded);
|
|
|
+ count++;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ long oldestDate = KVUtils.getDefault().getLong(KEY_OLDEST_IMAGE_ADDED_DATE, Long.MAX_VALUE);
|
|
|
+ String selection = MediaStore.Images.Media.DATE_ADDED + " >= ?";
|
|
|
+ String[] selectionArgs = new String[]{String.valueOf(oldestDate)};
|
|
|
+ try (Cursor cursor = contentResolver.query(
|
|
|
+ collection,
|
|
|
+ projection,
|
|
|
+ selection,
|
|
|
+ selectionArgs,
|
|
|
+ sortOrder
|
|
|
+ )) {
|
|
|
+ if (cursor == null) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ List<String> imagePaths = new ArrayList<>();
|
|
|
+ while (cursor.moveToNext()) {
|
|
|
+ long id = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Images.Media._ID));
|
|
|
+ Uri imageUri = Uri.withAppendedPath(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, String.valueOf(id));
|
|
|
+ String path = imageUri.getPath();
|
|
|
+ if (path == null || path.isEmpty() || imagePaths.contains(path)) {
|
|
|
+ continue; // skip if path is null or already processed
|
|
|
+ }
|
|
|
+ imagePaths.add(path);
|
|
|
+ }
|
|
|
+ checkRecoveredImages(imagePaths);
|
|
|
+ }
|
|
|
+ cleanupOldBackups();
|
|
|
+ }
|
|
|
+
|
|
|
+ private long getBitmapSizePerPixel(String displayName, String mimeType) {
|
|
|
+ if (displayName == null || displayName.isEmpty() || mimeType == null || mimeType.isEmpty()) {
|
|
|
+ return 3; // default to 3 bytes per pixel for unknown types
|
|
|
+ }
|
|
|
+ if (mimeType.equalsIgnoreCase("image/png")) {
|
|
|
+ return 4; // PNG typically uses 4 bytes per pixel
|
|
|
+ } else if (mimeType.equalsIgnoreCase("image/jpeg")) {
|
|
|
+ return 3; // JPEG typically uses 3 bytes per pixel
|
|
|
+ } else if (mimeType.equalsIgnoreCase("image/webp")) {
|
|
|
+ return 4; // WebP typically uses 4 bytes per pixel
|
|
|
+ }
|
|
|
+ return 3; // default to 4 bytes per pixel for unknown types
|
|
|
+ }
|
|
|
+
|
|
|
+ private void recordDateAdded(long dateAdded) {
|
|
|
+ long oldest = KVUtils.getDefault().getLong(KEY_OLDEST_IMAGE_ADDED_DATE, Long.MAX_VALUE);
|
|
|
+ if (dateAdded < oldest) {
|
|
|
+ KVUtils.getDefault().putLong(KEY_OLDEST_IMAGE_ADDED_DATE, dateAdded);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * @noinspection ResultOfMethodCallIgnored
|
|
|
+ */
|
|
|
+ private void backupImage(Uri imageUri, long bytesPerPixel) {
|
|
|
+ String imagePath = imageUri.getPath();
|
|
|
+ if (imagePath == null || imagePath.isEmpty()) {
|
|
|
+ return; // invalid image URI
|
|
|
+ }
|
|
|
+ if (getExistsBackupFile(imagePath) != null || getExistsRecoveryFile(imagePath) != null) {
|
|
|
+ AtmobLog.d(TAG, "Image already backed up: %s", imagePath);
|
|
|
+ return; // already backed up
|
|
|
+ }
|
|
|
+ try (InputStream inputStream = contentResolver.openInputStream(imageUri)) {
|
|
|
+ BitmapFactory.Options options = new BitmapFactory.Options();
|
|
|
+ options.inJustDecodeBounds = true;
|
|
|
+ BitmapFactory.decodeStream(inputStream, null, options);
|
|
|
+ if (options.outWidth <= 0 || options.outHeight <= 0) {
|
|
|
+ AtmobLog.w(TAG, "Invalid image dimensions for URI: %s", imageUri);
|
|
|
+ return; // invalid image
|
|
|
+ }
|
|
|
+ // calculate image size, and check if it exceeds the limit, compress if necessary
|
|
|
+ long imageSize = (long) options.outWidth * options.outHeight * bytesPerPixel;
|
|
|
+ if (imageSize > MAX_BITMAP_SIZE) {
|
|
|
+ options.inSampleSize = (int) Math.floor(Math.sqrt((double) imageSize / MAX_BITMAP_SIZE));
|
|
|
+ options.inSampleSize = Math.max(options.inSampleSize, 1); // ensure at least 1
|
|
|
+ } else {
|
|
|
+ options.inSampleSize = 1; // no compression needed
|
|
|
+ }
|
|
|
+ AtmobLog.d(TAG, "Image size (%d bytes) exceeds limit (%d bytes), compressing with inSampleSize: %d",
|
|
|
+ imageSize, MAX_BITMAP_SIZE, options.inSampleSize);
|
|
|
+ options.inJustDecodeBounds = false;
|
|
|
+ try (InputStream compressedInputStream = contentResolver.openInputStream(imageUri);
|
|
|
+ ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
|
|
|
+ Bitmap bitmap = BitmapFactory.decodeStream(compressedInputStream, null, options);
|
|
|
+ if (bitmap == null) {
|
|
|
+ AtmobLog.w(TAG, "Failed to decode image from URI: %s", imageUri);
|
|
|
+ return; // failed to decode image
|
|
|
+ }
|
|
|
+ compressBitmap(outputStream, bitmap);
|
|
|
+ File backupFile = new File(getBackupDir(), CryptoUtils.HASH.md5(imagePath));
|
|
|
+ if (backupFile.exists()) {
|
|
|
+ backupFile.delete();
|
|
|
+ } else {
|
|
|
+ backupFile.createNewFile();
|
|
|
+ }
|
|
|
+ try (FileOutputStream fileOutputStream = new FileOutputStream(backupFile)) {
|
|
|
+ outputStream.writeTo(fileOutputStream);
|
|
|
+ fileOutputStream.flush();
|
|
|
+ }
|
|
|
+ AtmobLog.d(TAG, "Image backed up successfully: %s, backup: %s",
|
|
|
+ imagePath, backupFile.getPath());
|
|
|
+ }
|
|
|
+ } catch (Exception e) {
|
|
|
+ e.printStackTrace(); // handle exception
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private static void compressBitmap(ByteArrayOutputStream outputStream, Bitmap bitmap) {
|
|
|
+ int low = 5; // Minimum bestQuality
|
|
|
+ int high = 100;
|
|
|
+ if (bitmap == null || outputStream == null) {
|
|
|
+ AtmobLog.w(TAG, "Bitmap or output stream is null, skipping compression.");
|
|
|
+ return; // invalid input
|
|
|
+ }
|
|
|
+ if (bitmap.getWidth() <= 0 || bitmap.getHeight() <= 0) {
|
|
|
+ AtmobLog.w(TAG, "Invalid bitmap dimensions: %dx%d, skipping compression.",
|
|
|
+ bitmap.getWidth(), bitmap.getHeight());
|
|
|
+ return; // invalid bitmap
|
|
|
+ }
|
|
|
+ outputStream.reset();
|
|
|
+ bitmap.compress(Bitmap.CompressFormat.WEBP, high, outputStream);
|
|
|
+ int size = outputStream.size();
|
|
|
+ if (size <= MAX_SIZE_PER_IMAGE) {
|
|
|
+ AtmobLog.d(TAG, "Image already within size limit (%d bytes), no compression needed.", size);
|
|
|
+ return; // already within size limit
|
|
|
+ }
|
|
|
+ outputStream.reset();
|
|
|
+ bitmap.compress(Bitmap.CompressFormat.WEBP, low, outputStream);
|
|
|
+ size = outputStream.size();
|
|
|
+ if (size > MAX_SIZE_PER_IMAGE) {
|
|
|
+ AtmobLog.w(TAG, "Image size (%d bytes) exceeds limit (%d bytes), compressing further.", size, MAX_SIZE_PER_IMAGE);
|
|
|
+ return; // still exceeds size limit, need to compress further
|
|
|
+ }
|
|
|
+ while (low < high) {
|
|
|
+ int mid = (low + high) / 2;
|
|
|
+ outputStream.reset();
|
|
|
+ bitmap.compress(Bitmap.CompressFormat.WEBP, mid, outputStream);
|
|
|
+ size = outputStream.size();
|
|
|
+ if (size <= MAX_SIZE_PER_IMAGE) {
|
|
|
+ low = mid + 1; // Try for a better bestQuality
|
|
|
+ } else {
|
|
|
+ high = mid; // Reduce bestQuality
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * @noinspection ResultOfMethodCallIgnored
|
|
|
+ */
|
|
|
+ private void checkRecoveredImages(List<String> imagePaths) {
|
|
|
+ List<String> imagePathsMD5 = new ArrayList<>();
|
|
|
+ for (String path : imagePaths) {
|
|
|
+ String pathMd5 = CryptoUtils.HASH.md5(path);
|
|
|
+ imagePathsMD5.add(pathMd5);
|
|
|
+ AtmobLog.d(TAG, "Checking image: %s, MD5: %s", path, pathMd5);
|
|
|
+ }
|
|
|
+ File backupDir = getBackupDir();
|
|
|
+ File[] files = backupDir.listFiles((dir, name) -> {
|
|
|
+ if (name == null || name.isEmpty()) {
|
|
|
+ return false; // skip empty names
|
|
|
+ }
|
|
|
+ // Check if the file name (MD5 hash) is not in the list of image
|
|
|
+ if (imagePathsMD5.contains(name)) {
|
|
|
+ AtmobLog.d(TAG, "Image still exists, skipping recovery: %s", name);
|
|
|
+ return false; // skip if already backed up
|
|
|
+ } else {
|
|
|
+ AtmobLog.d(TAG, "Image not found in backup, recovering: %s", name);
|
|
|
+ return true; // recover this file
|
|
|
+ }
|
|
|
+ });
|
|
|
+ if (files == null) {
|
|
|
+ return; // no files to recover
|
|
|
+ }
|
|
|
+ for (File file : files) {
|
|
|
+ AtmobLog.d(TAG, "Recovering file: %s", file.getName());
|
|
|
+ // move file to recovery directory and delete from backup
|
|
|
+ if (file.isDirectory() || file.length() <= 0) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ File recoveryDir = getRecoveryDir();
|
|
|
+ File recoveryFile = new File(recoveryDir, file.getName());
|
|
|
+ if (recoveryFile.exists()) {
|
|
|
+ recoveryFile.delete(); // delete existing file in recovery dir
|
|
|
+ }
|
|
|
+ if (file.renameTo(recoveryFile)) {
|
|
|
+ AtmobLog.d(TAG, "Recovery successful: %s -> %s",
|
|
|
+ file.getPath(), recoveryFile.getPath());
|
|
|
+ file.delete(); // delete from backup dir if moved successfully
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private void cleanupOldBackups() {
|
|
|
+ File backupDir = getBackupDir();
|
|
|
+ File[] backupFiles = backupDir.listFiles();
|
|
|
+ if (backupFiles == null || backupFiles.length == 0) {
|
|
|
+ return; // No backups to clean up
|
|
|
+ }
|
|
|
+ long totalSize = 0;
|
|
|
+ for (File file : backupFiles) {
|
|
|
+ if (file.isFile()) {
|
|
|
+ totalSize += file.length();
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (totalSize <= MAX_BACKUP_SIZE) {
|
|
|
+ return; // No need to clean up
|
|
|
+ }
|
|
|
+ AtmobLog.d(TAG, "Total backup size (%d bytes) exceeds limit (%d bytes), cleaning up old backups...", totalSize, MAX_BACKUP_SIZE);
|
|
|
+ // Sort files by last modified time and delete oldest files until under limit
|
|
|
+ Arrays.sort(backupFiles, (f1, f2) -> Long.compare(f1.lastModified(), f2.lastModified()));
|
|
|
+ for (File file : backupFiles) {
|
|
|
+ if (totalSize <= MAX_BACKUP_SIZE) {
|
|
|
+ break; // Under limit, stop deleting
|
|
|
+ }
|
|
|
+ if (file.delete()) {
|
|
|
+ AtmobLog.d(TAG, "Deleted old backup file: %s", file.getName());
|
|
|
+ totalSize -= file.length();
|
|
|
+ }
|
|
|
+ }
|
|
|
+ AtmobLog.d(TAG, "Cleanup complete. Remaining backup size: %d bytes", totalSize);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * @noinspection ResultOfMethodCallIgnored
|
|
|
+ */
|
|
|
+ private File getBackupDir() {
|
|
|
+ File backupDir = new File(rootDir, BACKUP_DIR);
|
|
|
+ if (backupDir.exists() && backupDir.isDirectory()) {
|
|
|
+ return backupDir;
|
|
|
+ }
|
|
|
+ backupDir.mkdir();
|
|
|
+ return backupDir;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * @noinspection ResultOfMethodCallIgnored
|
|
|
+ */
|
|
|
+ private File getRecoveryDir() {
|
|
|
+ File recoveryDir = new File(rootDir, RECOVERY_DIR);
|
|
|
+ if (recoveryDir.exists() && recoveryDir.isDirectory()) {
|
|
|
+ return recoveryDir;
|
|
|
+ }
|
|
|
+ recoveryDir.mkdir();
|
|
|
+ return recoveryDir;
|
|
|
+ }
|
|
|
+
|
|
|
+ private File getExistsBackupFile(String imagePath) {
|
|
|
+ if (imagePath == null) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ String pathMd5 = CryptoUtils.HASH.md5(imagePath);
|
|
|
+ File backupFile = new File(getBackupDir(), pathMd5);
|
|
|
+ if (backupFile.exists() && backupFile.isFile() && backupFile.length() > 0) {
|
|
|
+ return backupFile;
|
|
|
+ }
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ private File getExistsRecoveryFile(String imagePath) {
|
|
|
+ if (imagePath == null) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ String pathMd5 = CryptoUtils.HASH.md5(imagePath);
|
|
|
+ File recoveryFile = new File(getRecoveryDir(), pathMd5);
|
|
|
+ if (recoveryFile.exists() && recoveryFile.isFile() && recoveryFile.length() > 0) {
|
|
|
+ return recoveryFile;
|
|
|
+ }
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+}
|